diff --git a/.env b/.env index c8a9eba..e87c543 100644 --- a/.env +++ b/.env @@ -23,6 +23,6 @@ PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022 PTERO_SERVER_SFTP_USER=army.37a11489 PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM 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_URL=wss://ws.ironieopen.de:8081/telemetry +NEXT_PUBLIC_CS2_WS_HOST=ws.ironieopen.de NEXT_PUBLIC_CS2_WS_PORT=8081 \ No newline at end of file diff --git a/src/app/components/LiveRadar.tsx b/src/app/components/LiveRadar.tsx index 0046170..7c5b9d2 100644 --- a/src/app/components/LiveRadar.tsx +++ b/src/app/components/LiveRadar.tsx @@ -1,8 +1,7 @@ // /app/components/LiveRadar.tsx 'use client' -import { useEffect, useMemo, useState } from 'react' -import Button from './Button' +import { useEffect, useMemo, useRef, useState } from 'react' import LoadingSpinner from './LoadingSpinner' type Props = { matchId: string } @@ -13,15 +12,45 @@ type ApiResponse = { mapVisuals?: Record } +type PlayerState = { + id: string + name?: string | null + team?: 'T' | 'CT' | string + x: number + y: number + z: number + yaw: number + alive?: boolean +} + 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 --- + // WS-Status + von WS gemeldete Map const [wsStatus, setWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle') + const [wsMapKey, setWsMapKey] = useState(null) // <<— NEU - // 1) MapVote laden (welche Map wurde gewählt?) + // Headerhöhe → max Bildhöhe + const headerRef = useRef(null) + const [maxImgHeight, setMaxImgHeight] = useState(null) + useEffect(() => { + const updateMax = () => { + const bottom = headerRef.current?.getBoundingClientRect().bottom ?? 0 + const h = Math.max(120, Math.floor(window.innerHeight - bottom - 16)) + setMaxImgHeight(h) + } + updateMax() + window.addEventListener('resize', updateMax) + window.addEventListener('scroll', updateMax, { passive: true }) + return () => { + window.removeEventListener('resize', updateMax) + window.removeEventListener('scroll', updateMax) + } + }, []) + + // 1) MapVote laden (Fallback, falls WS-Map noch nicht kam) useEffect(() => { let canceled = false const load = async () => { @@ -45,16 +74,39 @@ export default function LiveRadar({ matchId }: Props) { return () => { canceled = true } }, [matchId]) - // 2) Beim Betreten des Radars mit dem CS2-WS-Server verbinden und alles loggen + // ========= Spieler-Overlay ========= + const playersRef = useRef>(new Map()) + const [players, setPlayers] = useState([]) + const flushTimer = useRef(null) + const scheduleFlush = () => { + if (flushTimer.current != null) return + flushTimer.current = window.setTimeout(() => { + flushTimer.current = null + setPlayers(Array.from(playersRef.current.values())) + }, 66) + } + function parsePlayer(p: any): PlayerState | null { + if (!p) return null + const id = p.steamId || p.steam_id || p.userId || p.playerId || p.id || p.name + if (!id) return null + const pos = p.pos || p.position || p.location || p.coordinates + const x = Number(p.x ?? pos?.x ?? (Array.isArray(pos) ? pos[0] : undefined)) + const y = Number(p.y ?? pos?.y ?? (Array.isArray(pos) ? pos[1] : undefined)) + const z = Number(p.z ?? pos?.z ?? (Array.isArray(pos) ? pos[2] : undefined)) + if (!Number.isFinite(x) || !Number.isFinite(y)) return null + const yaw = Number(p.yaw ?? p.ang?.y ?? p.angles?.y ?? p.rotation?.yaw ?? p.view?.yaw ?? 0) + return { id: String(id), name: p.name, team: p.team, x, y, z: Number.isFinite(z) ? z : 0, yaw, alive: p.alive } + } + + // 2) WS verbinden — zusätzlich Map-Meldung verarbeiten 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}` + const path = process.env.NEXT_PUBLIC_CS2_WS_PATH || '/telemetry' + const url = explicit || `${proto}://${host}:${port}${path}` let alive = true let ws: WebSocket | null = null @@ -71,13 +123,33 @@ export default function LiveRadar({ matchId }: Props) { } 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) + let msg: any = null + try { msg = JSON.parse(ev.data as string) } catch { /* ignore raw */ } + + // --- NEU: Map-Meldung direkt auswerten --- + if (msg && msg.type === 'map' && typeof msg.name === 'string') { + // msg.name z.B. "de_dust2" + setWsMapKey(msg.name) + return + } + + // Spieler-Formate + const candidates: any[] = [] + if (Array.isArray(msg)) candidates.push(...msg) + else if (msg?.type === 'players' && Array.isArray(msg.players)) candidates.push(...msg.players) + else if (Array.isArray(msg?.players)) candidates.push(...msg.players) + else if (Array.isArray(msg?.telemetry?.players)) candidates.push(...msg.telemetry.players) + else if (msg?.type === 'player' || msg?.steamId || msg?.pos || msg?.position) candidates.push(msg) + + if (candidates.length) { + let changed = false + for (const raw of candidates) { + const p = parsePlayer(raw) + if (!p) continue + playersRef.current.set(p.id, p) + changed = true + } + if (changed) scheduleFlush() } } @@ -89,52 +161,67 @@ export default function LiveRadar({ matchId }: Props) { 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) - } + if (alive) 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(() => { + // Fallback-Map aus dem Voting (erste gespielte) + const votedFirstMapKey = 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]) + // Aktive Map: WS > Voting + const activeMapKey = wsMapKey ?? votedFirstMapKey - // Radar-Datei(en) + // Label + const mapLabel = useMemo(() => { + if (!activeMapKey) return 'Unbekannte Map' + return data?.mapVisuals?.[activeMapKey]?.label ?? activeMapKey + }, [data, activeMapKey]) + + // Radar-Datei(en) für aktive Map 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}` + if (!activeMapKey) return { folderKey: null as string | null, candidates: [] as string[] } + const key = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey + const base = `/assets/img/radar/${activeMapKey}` // Ordner ist [mapKey], z.B. de_dust2 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 + `${base}/de_${key}_radar.png`, ] return { folderKey: key, candidates: list } - }, [firstMapKey]) + }, [activeMapKey]) const [srcIdx, setSrcIdx] = useState(0) useEffect(() => { setSrcIdx(0) }, [folderKey]) const currentSrc = candidates[srcIdx] - // kleines Badge für WS-Status + // Bildgröße für SVG-Overlay + const [imgSize, setImgSize] = useState<{w: number, h: number} | null>(null) + + // Welt→Bild (0/0/0 = Bildmitte) + const DEFAULT_WORLD_RADIUS = 4096 + const pxPerUnit = useMemo(() => { + if (!imgSize) return 0.1 + const spanPx = Math.min(imgSize.w, imgSize.h) + return spanPx / (2 * DEFAULT_WORLD_RADIUS) + }, [imgSize]) + const worldToPx = (x: number, y: number) => { + if (!imgSize) return { x: 0, y: 0 } + const cx = imgSize.w / 2, cy = imgSize.h / 2 + return { x: cx + x * pxPerUnit, y: cy - y * pxPerUnit } + } + const WsDot = ({ status }: { status: typeof wsStatus }) => { const color = status === 'open' ? 'bg-green-500' : @@ -156,7 +243,7 @@ export default function LiveRadar({ matchId }: Props) { return (
-
+

Live Radar

{mapLabel}
@@ -168,25 +255,59 @@ export default function LiveRadar({ matchId }: Props) {
) : error ? (
{error}
- ) : !firstMapKey ? ( + ) : !activeMapKey ? (
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.') - }} - /> + <> + {mapLabel} { + const img = e.currentTarget + setImgSize({ w: img.naturalWidth, h: img.naturalHeight }) + }} + onError={() => { + if (srcIdx < candidates.length - 1) setSrcIdx(i => i + 1) + else setError('Radar-Grafik nicht gefunden.') + }} + /> + {imgSize && ( + + {players.map((p) => { + const { x, y } = worldToPx(p.x, p.y) + const dirLen = Math.max(18, Math.min(imgSize.w, imgSize.h) * 0.025) + const rad = (p.yaw * Math.PI) / 180 + const dx = Math.cos(rad) * dirLen + const dy = -Math.sin(rad) * dirLen + const r = Math.max(4, Math.min(imgSize.w, imgSize.h) * 0.008) + const color = p.team === 'CT' ? '#3b82f6' : p.team === 'T' ? '#f59e0b' : '#10b981' + const strokeW = Math.max(1.5, r * 0.35) + + return ( + + + + + ) + })} + + )} + ) : (
Keine Radar-Grafik gefunden.
)}