diff --git a/.env b/.env index d8fcfbf..c8a9eba 100644 --- a/.env +++ b/.env @@ -22,4 +22,7 @@ PTERO_PANEL_URL=https://panel.ironieopen.de PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022 PTERO_SERVER_SFTP_USER=army.37a11489 PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM -PTERO_SERVER_ID=37a11489 \ No newline at end of file +PTERO_SERVER_ID=37a11489 +NEXT_PUBLIC_CS2_WS_URL=wss://cs2.ironieopen.de:8081/telemetry +NEXT_PUBLIC_CS2_WS_HOST=cs2.ironieopen.de +NEXT_PUBLIC_CS2_WS_PORT=8081 \ No newline at end of file diff --git a/public/assets/img/radar/de_ancient/de_ancient_radar_psd.png b/public/assets/img/radar/de_ancient/de_ancient_radar_psd.png new file mode 100644 index 0000000..0de7a45 Binary files /dev/null and b/public/assets/img/radar/de_ancient/de_ancient_radar_psd.png differ diff --git a/public/assets/img/radar/de_ancient/de_ancient_v1_radar_psd.png b/public/assets/img/radar/de_ancient/de_ancient_v1_radar_psd.png new file mode 100644 index 0000000..0de7a45 Binary files /dev/null and b/public/assets/img/radar/de_ancient/de_ancient_v1_radar_psd.png differ diff --git a/public/assets/img/radar/de_anubis/de_anubis_radar_psd.png b/public/assets/img/radar/de_anubis/de_anubis_radar_psd.png new file mode 100644 index 0000000..3d19d0a Binary files /dev/null and b/public/assets/img/radar/de_anubis/de_anubis_radar_psd.png differ diff --git a/public/assets/img/radar/de_dust2/de_dust2_radar_psd.png b/public/assets/img/radar/de_dust2/de_dust2_radar_psd.png new file mode 100644 index 0000000..b07a2c6 Binary files /dev/null and b/public/assets/img/radar/de_dust2/de_dust2_radar_psd.png differ diff --git a/public/assets/img/radar/de_inferno/de_inferno_radar_psd.png b/public/assets/img/radar/de_inferno/de_inferno_radar_psd.png new file mode 100644 index 0000000..bb66f3a Binary files /dev/null and b/public/assets/img/radar/de_inferno/de_inferno_radar_psd.png differ diff --git a/public/assets/img/radar/de_mirage/de_mirage_radar_psd.png b/public/assets/img/radar/de_mirage/de_mirage_radar_psd.png new file mode 100644 index 0000000..8ff8669 Binary files /dev/null and b/public/assets/img/radar/de_mirage/de_mirage_radar_psd.png differ diff --git a/public/assets/img/radar/de_nuke/de_nuke_lower_radar_psd.png b/public/assets/img/radar/de_nuke/de_nuke_lower_radar_psd.png new file mode 100644 index 0000000..60f20da Binary files /dev/null and b/public/assets/img/radar/de_nuke/de_nuke_lower_radar_psd.png differ diff --git a/public/assets/img/radar/de_nuke/de_nuke_radar_psd.png b/public/assets/img/radar/de_nuke/de_nuke_radar_psd.png new file mode 100644 index 0000000..8cc5eac Binary files /dev/null and b/public/assets/img/radar/de_nuke/de_nuke_radar_psd.png differ diff --git a/public/assets/img/radar/de_overpass/de_overpass_radar_psd.png b/public/assets/img/radar/de_overpass/de_overpass_radar_psd.png new file mode 100644 index 0000000..1566cab Binary files /dev/null and b/public/assets/img/radar/de_overpass/de_overpass_radar_psd.png differ diff --git a/public/assets/img/radar/de_train/de_train_lower_radar_psd.png b/public/assets/img/radar/de_train/de_train_lower_radar_psd.png new file mode 100644 index 0000000..ed867f8 Binary files /dev/null and b/public/assets/img/radar/de_train/de_train_lower_radar_psd.png differ diff --git a/public/assets/img/radar/de_train/de_train_radar_psd.png b/public/assets/img/radar/de_train/de_train_radar_psd.png new file mode 100644 index 0000000..873c008 Binary files /dev/null and b/public/assets/img/radar/de_train/de_train_radar_psd.png differ diff --git a/public/assets/img/radar/de_vertigo/de_vertigo_lower_radar_psd.png b/public/assets/img/radar/de_vertigo/de_vertigo_lower_radar_psd.png new file mode 100644 index 0000000..b184f07 Binary files /dev/null and b/public/assets/img/radar/de_vertigo/de_vertigo_lower_radar_psd.png differ diff --git a/public/assets/img/radar/de_vertigo/de_vertigo_radar_psd.png b/public/assets/img/radar/de_vertigo/de_vertigo_radar_psd.png new file mode 100644 index 0000000..93658c3 Binary files /dev/null and b/public/assets/img/radar/de_vertigo/de_vertigo_radar_psd.png differ diff --git a/src/app/components/LiveRadar.tsx b/src/app/components/LiveRadar.tsx new file mode 100644 index 0000000..0046170 --- /dev/null +++ b/src/app/components/LiveRadar.tsx @@ -0,0 +1,202 @@ +// /app/components/LiveRadar.tsx +'use client' + +import { useEffect, useMemo, useState } from 'react' +import Button from './Button' +import LoadingSpinner from './LoadingSpinner' + +type Props = { matchId: string } + +type ApiStep = { action: 'ban'|'pick'|'decider', map?: string | null } +type ApiResponse = { + steps: ApiStep[] + mapVisuals?: Record +} + +export default function LiveRadar({ matchId }: Props) { + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [data, setData] = useState(null) + + // --- WS Status für Header --- + const [wsStatus, setWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle') + + // 1) MapVote laden (welche Map wurde gewählt?) + useEffect(() => { + let canceled = false + const load = async () => { + setLoading(true); setError(null) + try { + const r = await fetch(`/api/matches/${matchId}/mapvote`, { cache: 'no-store' }) + if (!r.ok) { + const j = await r.json().catch(() => ({})) + throw new Error(j?.message || 'Laden fehlgeschlagen') + } + const json = await r.json() + if (!Array.isArray(json?.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)') + if (!canceled) setData(json) + } catch (e: any) { + if (!canceled) setError(e?.message ?? 'Unbekannter Fehler') + } finally { + if (!canceled) setLoading(false) + } + } + load() + return () => { canceled = true } + }, [matchId]) + + // 2) Beim Betreten des Radars mit dem CS2-WS-Server verbinden und alles loggen + useEffect(() => { + if (typeof window === 'undefined') return + + // Priorität: explizite URL > Host/Port > Fallback auf aktuelle Hostname:8081 + const explicit = process.env.NEXT_PUBLIC_CS2_WS_URL + const host = process.env.NEXT_PUBLIC_CS2_WS_HOST || window.location.hostname + const port = process.env.NEXT_PUBLIC_CS2_WS_PORT || '8081' + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const url = explicit || `${proto}://${host}:${port}` + + let alive = true + let ws: WebSocket | null = null + let retryTimer: number | null = null + + const connect = () => { + if (!alive) return + setWsStatus('connecting') + ws = new WebSocket(url) + + ws.onopen = () => { + setWsStatus('open') + console.info('[cs2-ws] connected →', url) + } + + ws.onmessage = (ev) => { + // Rohdaten + JSON-parsed in der Konsole anzeigen + const raw = ev.data + try { + const parsed = JSON.parse(raw as string) + console.log('[cs2-ws] message (json):', parsed) + } catch { + console.log('[cs2-ws] message (raw):', raw) + } + } + + ws.onerror = (err) => { + setWsStatus('error') + console.error('[cs2-ws] error:', err) + } + + ws.onclose = (ev) => { + console.warn('[cs2-ws] closed:', ev.code, ev.reason) + setWsStatus('closed') + if (alive) { + // simpler Reconnect nach 2s + retryTimer = window.setTimeout(connect, 2000) + } + } + } + + connect() + + return () => { + alive = false + if (retryTimer) window.clearTimeout(retryTimer) + try { ws?.close(1000, 'radar unmounted') } catch {} + } + }, []) // nur einmal beim Mount auf /radar verbinden + + // Erste gespielte Map (Pick/Decider in Anzeige-Reihenfolge) + const firstMapKey = useMemo(() => { + const chosen = (data?.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map) + return chosen[0]?.map ?? null + }, [data]) + + const mapLabel = useMemo(() => { + if (!firstMapKey) return 'Unbekannte Map' + return data?.mapVisuals?.[firstMapKey]?.label ?? firstMapKey + }, [data, firstMapKey]) + + // Radar-Datei(en) + const { folderKey, candidates } = useMemo(() => { + if (!firstMapKey) return { folderKey: null as string | null, candidates: [] as string[] } + const key = firstMapKey.startsWith('de_') ? firstMapKey.slice(3) : firstMapKey + const base = `/assets/img/radar/${key}` + const list = [ + `${base}/de_${key}_radar_psd.png`, + `${base}/de_${key}_lower_radar_psd.png`, + `${base}/de_${key}_v1_radar_psd.png`, + `${base}/de_${key}_radar.png`, // optionaler Fallback + ] + return { folderKey: key, candidates: list } + }, [firstMapKey]) + + const [srcIdx, setSrcIdx] = useState(0) + useEffect(() => { setSrcIdx(0) }, [folderKey]) + const currentSrc = candidates[srcIdx] + + // kleines Badge für WS-Status + const WsDot = ({ status }: { status: typeof wsStatus }) => { + const color = + status === 'open' ? 'bg-green-500' : + status === 'connecting' ? 'bg-amber-500' : + status === 'error' ? 'bg-red-500' : + 'bg-neutral-400' + const label = + status === 'open' ? 'verbunden' : + status === 'connecting' ? 'verbinde…' : + status === 'error' ? 'Fehler' : + status === 'closed' ? 'getrennt' : '—' + return ( + + + {label} + + ) + } + + return ( +
+
+

Live Radar

+
+
{mapLabel}
+ +
+
+ + {loading ? ( +
+ ) : error ? ( +
{error}
+ ) : !firstMapKey ? ( +
+ Noch keine gespielte Map gefunden. +
+ ) : ( +
+ {/* großes Radar-Bild */} +
+ {currentSrc ? ( + {mapLabel} { + if (srcIdx < candidates.length - 1) setSrcIdx(i => i + 1) + else setError('Radar-Grafik nicht gefunden.') + }} + /> + ) : ( +
Keine Radar-Grafik gefunden.
+ )} +
+ +
+ Quelle: {currentSrc ?? '—'} +
+
+ )} +
+ ) +} diff --git a/src/app/components/MapVotePanel.tsx b/src/app/components/MapVotePanel.tsx index 553ebfa..323fce2 100644 --- a/src/app/components/MapVotePanel.tsx +++ b/src/app/components/MapVotePanel.tsx @@ -78,14 +78,12 @@ export default function MapVotePanel({ match }: Props) { if (lastEvent.payload?.matchId !== match.id) return const fm = lastEvent.payload?.firstMap ?? {} - showWithDelay( - { - matchId: match.id, - mapLabel: fm?.label ?? 'Erste Map', - mapBg: fm?.bg ?? '/assets/img/maps/cs2.webp', - }, - 3000 - ) + showWithDelay({ + matchId: match.id, + mapLabel: fm?.label ?? 'Erste Map', + mapBg: fm?.bg ?? '/assets/img/maps/cs2.webp', + nextHref: `/match-details/${match.id}/radar`, + }, 3000) }, [lastEvent, match.id, showWithDelay]) /* -------- Data load (initial + SSE refresh) -------- */ diff --git a/src/app/components/ReadyOverlayHost.tsx b/src/app/components/ReadyOverlayHost.tsx index c7f0198..73fe4b5 100644 --- a/src/app/components/ReadyOverlayHost.tsx +++ b/src/app/components/ReadyOverlayHost.tsx @@ -1,37 +1,39 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' import { useSession } from 'next-auth/react' import { useSSEStore } from '@/app/lib/useSSEStore' import MatchReadyOverlay from './MatchReadyOverlay' import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore' /** - * Erwartet ein SSE-Event 'map-vote-updated' bzw. 'match-ready' mit payload: - * { matchId: string, locked: boolean, bestOf: number, steps: [...], mapVisuals: { [k]: {label, bg} } } - * - * Falls dein Backend leichter anpassbar ist: schicke einfach ein schlankes 'match-ready' Event mit: - * { matchId, firstMap: { key, label, bg }, participants: string[] } - * Dann sparst du dir den Client-seitigen Ableitungscode unten. + * Erwartet SSE-Events: + * - 'match-ready' { matchId, firstMap:{label,bg}, participants:string[] } + * - 'map-vote-updated' { matchId, locked, bestOf, steps, mapVisuals, teams:{teamA,teamB} } */ export default function ReadyOverlayHost() { + const router = useRouter() const { data: session } = useSession() const mySteamId = session?.user?.steamId ?? null const { lastEvent } = useSSEStore() const { open, data, showWithDelay, hide } = useReadyOverlayStore() - // lokal gemerkte "bereits akzeptiert"-Flags pro Match + // --- LocalStorage-Flag: pro Match nur einmal anzeigen --- const isAccepted = (matchId: string) => - typeof window !== 'undefined' && window.localStorage.getItem(`match:${matchId}:readyAccepted`) === '1' + typeof window !== 'undefined' && + window.localStorage.getItem(`match:${matchId}:readyAccepted`) === '1' + const markAccepted = (matchId: string) => { - if (typeof window !== 'undefined') window.localStorage.setItem(`match:${matchId}:readyAccepted`, '1') + if (typeof window !== 'undefined') { + window.localStorage.setItem(`match:${matchId}:readyAccepted`, '1') + } } - // –– Helfer: aus einem map-vote-updated Payload die erste Map & Teilnehmer ableiten + // --- Ableitung firstMap + participants aus 'map-vote-updated' --- function deriveReadySummary(payload: any) { - // payload: { matchId, locked, bestOf, steps, mapVisuals, teams: { teamA:{players,leader}, teamB:{players,leader} } } const matchId: string | undefined = payload?.matchId if (!matchId) return null @@ -40,7 +42,6 @@ export default function ReadyOverlayHost() { const steps: any[] = Array.isArray(payload?.steps) ? payload.steps : [] const mapVisuals = payload?.mapVisuals ?? {} - // Teilnehmer ermitteln (aus votes-Teams oder Fallbacks) const playersA = payload?.teams?.teamA?.players ?? [] const playersB = payload?.teams?.teamB?.players ?? [] const participants: string[] = [ @@ -48,39 +49,44 @@ export default function ReadyOverlayHost() { ...playersB.map((p: any) => p?.steamId).filter(Boolean), ] - // Picks + Decider mit Map - const chosen = steps.filter(s => (s.action === 'pick' || s.action === 'decider') && s.map) + const chosen = steps.filter( + (s) => (s.action === 'pick' || s.action === 'decider') && s.map + ) const allChosen = locked && chosen.length >= bestOf - if (!allChosen) return null const first = chosen[0] const key = first?.map const label = key ? (mapVisuals?.[key]?.label ?? key) : '?' - const bg = key ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) : '/assets/img/maps/cs2.webp' + const bg = key + ? (mapVisuals?.[key]?.bg ?? `/assets/img/maps/${key}/1.jpg`) + : '/assets/img/maps/cs2.webp' - return { - matchId, - firstMap: { key, label, bg }, - participants, - } + return { matchId, firstMap: { key, label, bg }, participants } } - // –– SSE-Events beobachten (map-vote-updated oder match-ready) + // --- SSE: 'match-ready' / 'map-vote-updated' öffnen das Overlay --- useEffect(() => { if (!lastEvent || !mySteamId) return - console.log("Empfangen: ", lastEvent); - if (lastEvent.type === 'match-ready') { const m = lastEvent.payload?.matchId const participants: string[] = lastEvent.payload?.participants ?? [] - if (!m || !participants.includes(mySteamId)) return - if (isAccepted(m)) return + if (!m || !participants.includes(mySteamId) || isAccepted(m)) return + const label = lastEvent.payload?.firstMap?.label ?? '?' const bg = lastEvent.payload?.firstMap?.bg ?? '/assets/img/maps/cs2.webp' - // 3s Delay: - showWithDelay({ matchId: m, mapLabel: label, mapBg: bg }, 3000) + + // 3s Delay + Zielroute mitgeben: + showWithDelay( + { + matchId: m, + mapLabel: label, + mapBg: bg, + nextHref: `/match-details/${m}/radar`, // <- WICHTIG + }, + 3000 + ) return } @@ -88,14 +94,21 @@ export default function ReadyOverlayHost() { const summary = deriveReadySummary(lastEvent.payload) if (!summary) return const { matchId: m, firstMap, participants } = summary - if (!participants.includes(mySteamId)) return - if (isAccepted(m)) return - // 3s Delay: - showWithDelay({ matchId: m, mapLabel: firstMap.label, mapBg: firstMap.bg }, 3000) + if (!participants.includes(mySteamId) || isAccepted(m)) return + + showWithDelay( + { + matchId: m, + mapLabel: firstMap.label, + mapBg: firstMap.bg, + nextHref: `/match-details/${m}/radar`, // <- WICHTIG + }, + 3000 + ) } }, [lastEvent, mySteamId, showWithDelay]) - // Falls Voting zurückgesetzt wird: Overlay schließen + Flag löschen + // --- Reset-Event: Overlay schließen & Flag löschen --- useEffect(() => { if (!lastEvent) return if (lastEvent.type === 'map-vote-reset') { @@ -115,10 +128,11 @@ export default function ReadyOverlayHost() { mapLabel={data.mapLabel} mapBg={data.mapBg} onAccept={async () => { - // optional: Backend benachrichtigen + // optional: Backend informieren // await fetch(`/api/matches/${data.matchId}/ready-accept`, { method: 'POST' }).catch(() => {}) markAccepted(data.matchId) hide() + router.push(data.nextHref ?? `/match-details/${data.matchId}/radar`) // <- WICHTIG }} /> ) diff --git a/src/app/lib/useReadyOverlayStore.ts b/src/app/lib/useReadyOverlayStore.ts index 18fd278..e30c38a 100644 --- a/src/app/lib/useReadyOverlayStore.ts +++ b/src/app/lib/useReadyOverlayStore.ts @@ -6,12 +6,13 @@ type ReadyOverlayData = { matchId: string mapLabel: string mapBg: string + nextHref?: string // Zielroute nach "ACCEPT" } type State = { open: boolean data: ReadyOverlayData | null - showAt?: number | null // für 3s Delay + showAt?: number | null showWithDelay: (data: ReadyOverlayData, delayMs: number) => void hide: () => void } @@ -23,7 +24,6 @@ export const useReadyOverlayStore = create((set) => ({ showWithDelay: (data, delayMs) => { const showAt = Date.now() + Math.max(0, delayMs) set({ data, showAt }) - // kleiner Ticker um nach delayMs zu öffnen (ohne extra setTimeout-Leak) const step = () => { const t = Date.now() if (t >= showAt) { diff --git a/src/app/match-details/[matchId]/radar/page.tsx b/src/app/match-details/[matchId]/radar/page.tsx new file mode 100644 index 0000000..c02b8a6 --- /dev/null +++ b/src/app/match-details/[matchId]/radar/page.tsx @@ -0,0 +1,5 @@ +import LiveRadar from '@/app/components/LiveRadar' + +export default function RadarPage({ params }: { params: { matchId: string } }) { + return +} \ No newline at end of file