This commit is contained in:
Linrador 2025-08-19 10:29:40 +02:00
parent 640505bc1f
commit 83127a7c14
2 changed files with 171 additions and 50 deletions

4
.env
View File

@ -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

View File

@ -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<string, { label: string; bg: string }>
}
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<string | null>(null)
const [data, setData] = useState<ApiResponse | null>(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<string | null>(null) // <<— NEU
// 1) MapVote laden (welche Map wurde gewählt?)
// Headerhöhe → max Bildhöhe
const headerRef = useRef<HTMLDivElement | null>(null)
const [maxImgHeight, setMaxImgHeight] = useState<number | null>(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<Map<string, PlayerState>>(new Map())
const [players, setPlayers] = useState<PlayerState[]>([])
const flushTimer = useRef<number | null>(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 (
<div className="p-4">
<div className="mb-4 flex items-center justify-between">
<div ref={headerRef} className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Live Radar</h2>
<div className="flex items-center gap-3">
<div className="text-sm opacity-80">{mapLabel}</div>
@ -168,25 +255,59 @@ export default function LiveRadar({ matchId }: Props) {
<div className="p-8 flex justify-center"><LoadingSpinner /></div>
) : error ? (
<div className="p-4 text-red-600">{error}</div>
) : !firstMapKey ? (
) : !activeMapKey ? (
<div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
Noch keine gespielte Map gefunden.
</div>
) : (
<div className="w-full">
{/* großes Radar-Bild */}
<div className="relative w-full max-w-5xl mx-auto rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800">
<div
className="relative mx-auto rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 inline-block"
style={{ maxHeight: maxImgHeight ?? undefined }}
>
{currentSrc ? (
<img
key={currentSrc}
src={currentSrc}
alt={mapLabel}
className="w-full h-auto block"
onError={() => {
if (srcIdx < candidates.length - 1) setSrcIdx(i => i + 1)
else setError('Radar-Grafik nicht gefunden.')
}}
/>
<>
<img
key={currentSrc}
src={currentSrc}
alt={mapLabel}
className="block h-auto max-w-full"
style={{ maxHeight: maxImgHeight ?? undefined }}
onLoad={(e) => {
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 && (
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
preserveAspectRatio="xMidYMid meet"
>
{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 (
<g key={p.id}>
<line x1={x} y1={y} x2={x + dx} y2={y + dy} stroke={color} strokeWidth={strokeW} strokeLinecap="round" opacity={p.alive === false ? 0.5 : 1} />
<circle cx={x} cy={y} r={r} fill={color} stroke="#ffffff" strokeWidth={Math.max(1, r * 0.3)} opacity={p.alive === false ? 0.6 : 1} />
</g>
)
})}
</svg>
)}
</>
) : (
<div className="p-6 text-center">Keine Radar-Grafik gefunden.</div>
)}