This commit is contained in:
Linrador 2025-08-19 15:32:56 +02:00
parent 1fff4b6001
commit c10bd74b70
22 changed files with 727 additions and 257 deletions

View File

@ -1,29 +0,0 @@
"ar_baggage"
{
"material" "overviews/ar_baggage"
"pos_x" "-1316"
"pos_y" "1288"
"scale" "2.539062"
"rotate" "1"
"zoom" "1.300000"
"verticalsections"
{
"default" // use the primary radar image
{
"AltitudeMax" "10000"
"AltitudeMin" "-5"
}
"lower" // i.e. de_nuke_lower_radar.dds
{
"AltitudeMax" "-5"
"AltitudeMin" "-10000"
}
}
"CTSpawn_x" "0.510000"
"CTSpawn_y" "0.820000"
"TSpawn_x" "0.510000"
"TSpawn_y" "0.290000"
}

View File

@ -1,11 +0,0 @@
"ar_shoots"
{
"material" "overviews/ar_shoots"
"pos_x" "-1368"
"pos_y" "1952"
"scale" "2.687500"
"CTSpawn_x" "0.520000"
"CTSpawn_y" "0.270000"
"TSpawn_x" "0.520000"
"TSpawn_y" "0.710000"
}

View File

@ -1,11 +0,0 @@
"ar_shoots_night"
{
"material" "overviews/ar_shoots_night"
"pos_x" "-1368"
"pos_y" "1952"
"scale" "2.687500"
"CTSpawn_x" "0.520000"
"CTSpawn_y" "0.270000"
"TSpawn_x" "0.520000"
"TSpawn_y" "0.710000"
}

View File

@ -1,31 +0,0 @@
// HLTV overview description file for cs_italy.bsp
"cs_italy"
{
"material" "overviews/cs_italy" // texture file
"pos_x" "-2647" // upper left world coordinate
"pos_y" "2592"
"scale" "4.6"
"rotate" "1"
"zoom" "1.5"
// loading screen icons and positions
"CTSpawn_x" "0.41"
"CTSpawn_y" "0.91"
"TSpawn_x" "0.6"
"TSpawn_y" "0.1"
"Hostage1_x" "0.43"
"Hostage1_y" "0.29"
"Hostage2_x" "0.48"
"Hostage2_y" "0.24"
"Hostage3_x" "0.64"
"Hostage3_y" "0.03"
"Hostage4_x" "0.72"
"Hostage4_y" "0.05"
// "Hostage5_x" "0.8"
// "Hostage5_y" "0.3"
// "Hostage6_x" "0.6"
// "Hostage6_y" "0.9"
}

View File

@ -1,29 +0,0 @@
// HLTV overview description file for cs_office.bsp
"cs_office"
{
"material" "overviews/cs_office" // texture file
"pos_x" "-1838" // upper left world coordinate
"pos_y" "1858"
"scale" "4.1"
// loading screen icons and positions
"CTSpawn_x" "0.16"
"CTSpawn_y" "0.89"
"TSpawn_x" "0.78"
"TSpawn_y" "0.30"
"Hostage1_x" "0.84"
"Hostage1_y" "0.27"
"Hostage2_x" "0.84"
"Hostage2_y" "0.48"
"Hostage3_x" "0.91"
"Hostage3_y" "0.48"
"Hostage4_x" "0.77"
"Hostage4_y" "0.48"
"Hostage5_x" "0.77"
"Hostage5_y" "0.55"
// "Hostage6_x" "0.6"
// "Hostage6_y" "0.9"
}

View File

@ -1,23 +0,0 @@
// HLTV overview description file for de_ancient.bsp
"de_ancient"
{
"material" "overviews/de_ancient" // texture file
"pos_x" "-2953" // upper left world coordinate
"pos_y" "2164"
"scale" "5"
"rotate" "0"
"zoom" "0"
// loading screen icons and positions
"CTSpawn_x" "0.51"
"CTSpawn_y" "0.17"
"TSpawn_x" "0.485"
"TSpawn_y" "0.87"
"bombA_x" "0.31"
"bombA_y" "0.25"
"bombB_x" "0.80"
"bombB_y" "0.40"
}

View File

@ -4,14 +4,40 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
const RAD2DEG = 180 / Math.PI;
function normalizeDeg(d: number) { d = d % 360; return d < 0 ? d + 360 : d; }
function toYawDegMaybe(raw: any): number | null {
const v = Number(raw);
if (!Number.isFinite(v)) return null;
if (Math.abs(v) <= 2 * Math.PI + 1e-3) return v * RAD2DEG; // radians
return v; // degrees
}
function deriveYawDeg(raw: any, prev: PlayerState | undefined, x: number, y: number): number {
const fromRaw = toYawDegMaybe(raw);
if (fromRaw != null && Math.abs(fromRaw) > 1e-6 && Math.abs(fromRaw) < 1e6) return normalizeDeg(fromRaw);
if (prev) {
const dx = x - prev.x, dy = y - prev.y;
if (Math.hypot(dx, dy) > 1) return normalizeDeg(Math.atan2(dy, dx) * RAD2DEG);
if (Number.isFinite(prev.yaw)) return prev.yaw;
}
return 0;
}
function mapTeam(t: any): 'T' | 'CT' | string {
if (t === 2 || t === 'T' || t === 't') return 'T';
if (t === 3 || t === 'CT' || t === 'ct') return 'CT';
return String(t ?? '');
}
type Props = { matchId: string } type Props = { matchId: string }
type ApiStep = { action: 'ban'|'pick'|'decider', map?: string | null } // ---- API (MapVote) ----------------------------------------------------------
type ApiStep = { action: 'ban' | 'pick' | 'decider'; map?: string | null }
type ApiResponse = { type ApiResponse = {
steps: ApiStep[] steps: ApiStep[]
mapVisuals?: Record<string, { label: string; bg: string }> mapVisuals?: Record<string, { label: string; bg: string }>
} }
// ---- Telemetry Player -------------------------------------------------------
type PlayerState = { type PlayerState = {
id: string id: string
name?: string | null name?: string | null
@ -19,41 +45,103 @@ type PlayerState = {
x: number x: number
y: number y: number
z: number z: number
yaw: number yaw: number // 0 -> +X, 90 -> +Y (Welt)
alive?: boolean alive?: boolean
} }
// ---- Overview (HLTV) --------------------------------------------------------
type Overview = { posX: number; posY: number; scale: number; rotate?: number }
// ---- Effekte (Smoke/Fire) ---------------------------------------------------
type EffectType = 'smoke' | 'fire'
type Effect = {
id: string
type: 'smoke' | 'fire'
x: number
y: number
z: number
startMs: number
ttlMs: number
// neu:
ending?: boolean
fadeUntil?: number
}
// --- Nade-Pfade -------------------------------------------------------------
type NadePoint = { x: number; y: number; z?: number; t?: number; s?: number }
type NadePath = {
id: string
kind: string // 'smoke' | 'flash' | 'he' | 'molotov' | ...
points: NadePoint[]
startedMs: number
endedMs?: number
}
const NADE_PATH_TTL = 6000 // Pfad noch 6s nach Detonation zeigen
function nadeColor(kind: string) {
const k = String(kind || '').toLowerCase()
if (k.includes('smoke')) return '#94a3b8' // smoke: slate-grau
if (k.includes('flash')) return '#fbbf24' // flash: gelb
if (k.includes('molotov') || k.includes('incen') || k === 'fire') return '#f97316' // molly
if (k.includes('he') || k.includes('frag')) return '#ef4444' // HE
if (k.includes('decoy')) return '#22c55e' // decoy
return '#a3a3a3'
}
export default function LiveRadar({ matchId }: Props) { export default function LiveRadar({ matchId }: Props) {
// ---- MapVote (Backup)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<ApiResponse | null>(null) const [voteData, setVoteData] = useState<ApiResponse | null>(null)
// WS-Status + von WS gemeldete Map // ---- WS
const [wsStatus, setWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle') const [wsStatus, setWsStatus] =
const [wsMapKey, setWsMapKey] = useState<string | null>(null) // <<— NEU useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
// Headerhöhe → max Bildhöhe // ---- Layout: Bild maximal Viewport-Höhe
const headerRef = useRef<HTMLDivElement | null>(null) const headerRef = useRef<HTMLDivElement | null>(null)
const [maxImgHeight, setMaxImgHeight] = useState<number | null>(null) const [maxImgHeight, setMaxImgHeight] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
const updateMax = () => { const update = () => {
const bottom = headerRef.current?.getBoundingClientRect().bottom ?? 0 const bottom = headerRef.current?.getBoundingClientRect().bottom ?? 0
const h = Math.max(120, Math.floor(window.innerHeight - bottom - 16)) setMaxImgHeight(Math.max(120, Math.floor(window.innerHeight - bottom - 16)))
setMaxImgHeight(h)
} }
updateMax() update()
window.addEventListener('resize', updateMax) window.addEventListener('resize', update)
window.addEventListener('scroll', updateMax, { passive: true }) window.addEventListener('scroll', update, { passive: true })
return () => { return () => {
window.removeEventListener('resize', updateMax) window.removeEventListener('resize', update)
window.removeEventListener('scroll', updateMax) window.removeEventListener('scroll', update)
} }
}, []) }, [])
// 1) MapVote laden (Fallback, falls WS-Map noch nicht kam) // SMOKE + FIRE:
const SMOKE_PATH = "M32 400C32 479.5 96.5 544 176 544L480 544C550.7 544 608 486.7 608 416C608 364.4 577.5 319.9 533.5 299.7C540.2 286.6 544 271.7 544 256C544 203 501 160 448 160C430.3 160 413.8 164.8 399.6 173.1C375.5 127.3 327.4 96 272 96C192.5 96 128 160.5 128 240C128 248 128.7 255.9 129.9 263.5C73 282.7 32 336.6 32 400z";
const FIRE_PATH = "M256.5 37.6C265.8 29.8 279.5 30.1 288.4 38.5C300.7 50.1 311.7 62.9 322.3 75.9C335.8 92.4 352 114.2 367.6 140.1C372.8 133.3 377.6 127.3 381.8 122.2C382.9 120.9 384 119.5 385.1 118.1C393 108.3 402.8 96 415.9 96C429.3 96 438.7 107.9 446.7 118.1C448 119.8 449.3 121.4 450.6 122.9C460.9 135.3 474.6 153.2 488.3 175.3C515.5 219.2 543.9 281.7 543.9 351.9C543.9 475.6 443.6 575.9 319.9 575.9C196.2 575.9 96 475.7 96 352C96 260.9 137.1 182 176.5 127C196.4 99.3 216.2 77.1 231.1 61.9C239.3 53.5 247.6 45.2 256.6 37.7zM321.7 480C347 480 369.4 473 390.5 459C432.6 429.6 443.9 370.8 418.6 324.6C414.1 315.6 402.6 315 396.1 322.6L370.9 351.9C364.3 359.5 352.4 359.3 346.2 351.4C328.9 329.3 297.1 289 280.9 268.4C275.5 261.5 265.7 260.4 259.4 266.5C241.1 284.3 207.9 323.3 207.9 370.8C207.9 439.4 258.5 480 321.6 480z";
const SMOKE_FADE_MS = 3000; // 3s ausfaden
// NEU Größe & Opazität der Icons
const SMOKE_ICON_SCALE = 1.6; // >1 = größer
const FIRE_ICON_SCALE = 1.45;
const SMOKE_GROUP_OPACITY = 0.95; // weniger transparent
const SMOKE_FILL_OPACITY = 0.70;
const FIRE_GROUP_OPACITY = 1.0; // fast/komplett deckend
// ---- Nade-Pfade ------------------------------------------------------------
const nadePathsRef = useRef<Map<string, NadePath>>(new Map())
const [nadePaths, setNadePaths] = useState<NadePath[]>([])
const syncNadePaths = () => setNadePaths(Array.from(nadePathsRef.current.values()))
// ---- 1) MapVote laden
useEffect(() => { useEffect(() => {
let canceled = false let cancel = false
const load = async () => { ;(async () => {
setLoading(true); setError(null) setLoading(true); setError(null)
try { try {
const r = await fetch(`/api/matches/${matchId}/mapvote`, { cache: 'no-store' }) const r = await fetch(`/api/matches/${matchId}/mapvote`, { cache: 'no-store' })
@ -63,18 +151,17 @@ export default function LiveRadar({ matchId }: Props) {
} }
const json = await r.json() const json = await r.json()
if (!Array.isArray(json?.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)') if (!Array.isArray(json?.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
if (!canceled) setData(json) if (!cancel) setVoteData(json)
} catch (e:any) { } catch (e:any) {
if (!canceled) setError(e?.message ?? 'Unbekannter Fehler') if (!cancel) setError(e?.message ?? 'Unbekannter Fehler')
} finally { } finally {
if (!canceled) setLoading(false) if (!cancel) setLoading(false)
} }
} })()
load() return () => { cancel = true }
return () => { canceled = true }
}, [matchId]) }, [matchId])
// ========= Spieler-Overlay ========= // ---- Spieler-Overlay (ge-throttled)
const playersRef = useRef<Map<string, PlayerState>>(new Map()) const playersRef = useRef<Map<string, PlayerState>>(new Map())
const [players, setPlayers] = useState<PlayerState[]>([]) const [players, setPlayers] = useState<PlayerState[]>([])
const flushTimer = useRef<number | null>(null) const flushTimer = useRef<number | null>(null)
@ -85,20 +172,37 @@ export default function LiveRadar({ matchId }: Props) {
setPlayers(Array.from(playersRef.current.values())) setPlayers(Array.from(playersRef.current.values()))
}, 66) }, 66)
} }
// ---- Effekte-State (seltenere Updates, kein Throttle nötig aber Map + Sync)
const effectsRef = useRef<Map<string, Effect>>(new Map())
const [effects, setEffects] = useState<Effect[]>([])
const syncEffects = () => setEffects(Array.from(effectsRef.current.values()))
function parsePlayer(p: any): PlayerState | null { function parsePlayer(p: any): PlayerState | null {
if (!p) return null if (!p) return null
const id = p.steamId || p.steam_id || p.userId || p.playerId || p.id || p.name const id = p.steamId || p.steam_id || p.userId || p.playerId || p.id || p.name
if (!id) return null if (!id) return null
const pos = p.pos || p.position || p.location || p.coordinates const pos = p.pos || p.position || p.location || p.coordinates
const x = Number(p.x ?? pos?.x ?? (Array.isArray(pos) ? pos[0] : undefined)) 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 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)) const z = Number(p.z ?? pos?.z ?? (Array.isArray(pos) ? pos?.[2] : 0))
if (!Number.isFinite(x) || !Number.isFinite(y)) return null 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) 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 } return { id: String(id), name: p.name, team: p.team, x, y, z, yaw, alive: p.alive }
} }
// 2) WS verbinden — zusätzlich Map-Meldung verarbeiten // ---- Aktive Map (WS hat Vorrang)
const voteMapKey = useMemo(() => {
const chosen = (voteData?.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
return chosen[0]?.map ?? null
}, [voteData])
const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
useEffect(() => {
if (!activeMapKey && voteMapKey) setActiveMapKey(voteMapKey)
}, [voteMapKey, activeMapKey])
// ---- 2) WS verbinden: Map + Players + Effekte
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
const explicit = process.env.NEXT_PUBLIC_CS2_WS_URL const explicit = process.env.NEXT_PUBLIC_CS2_WS_URL
@ -110,118 +214,479 @@ export default function LiveRadar({ matchId }: Props) {
let alive = true let alive = true
let ws: WebSocket | null = null let ws: WebSocket | null = null
let retryTimer: number | null = null let retry: number | null = null
// Hilfsfunktionen für Effekte
const addEffect = (type: EffectType, m: any) => {
const pos = m.pos ?? m.position
const x = Number(m.x ?? pos?.x), y = Number(m.y ?? pos?.y), z = Number(m.z ?? pos?.z ?? 0)
if (!Number.isFinite(x) || !Number.isFinite(y)) return
const serverId = m.id ?? m.entityId ?? m.grenadeId ?? m.guid
const id = String(serverId ?? `${type}:${Math.round(x)}:${Math.round(y)}:${Math.round(m.t ?? Date.now())}`)
const ttlMs = type === 'smoke' ? 19000 : 7000 // Smoke ~19s, Molotov ~7s (Fallback)
effectsRef.current.set(id, { id, type, x, y, z, startMs: Date.now(), ttlMs })
syncEffects()
}
const removeEffect = (type: EffectType, m: any) => {
const serverId = m.id ?? m.entityId ?? m.grenadeId ?? m.guid
if (serverId != null) {
const key = String(serverId)
const e = effectsRef.current.get(key)
if (e) {
if (type === 'smoke') {
// statt löschen: Fade markieren
e.ending = true
e.fadeUntil = Date.now() + SMOKE_FADE_MS
effectsRef.current.set(key, e)
} else {
// fire weiterhin sofort entfernen
effectsRef.current.delete(key)
}
syncEffects()
}
return
}
// Fallback: nächster gleicher Typ in der Nähe
const pos = m.pos ?? m.position
const x = Number(m.x ?? pos?.x), y = Number(m.y ?? pos?.y)
if (Number.isFinite(x) && Number.isFinite(y)) {
let bestKey: string | null = null, bestD = Infinity
for (const [k, e] of effectsRef.current) {
if (e.type !== type) continue
const d = Math.hypot(e.x - x, e.y - y)
if (d < bestD) { bestD = d; bestKey = k }
}
if (bestKey && bestD < 200) {
if (type === 'smoke') {
const e = effectsRef.current.get(bestKey)!
e.ending = true
e.fadeUntil = Date.now() + SMOKE_FADE_MS
effectsRef.current.set(bestKey, e)
} else {
effectsRef.current.delete(bestKey)
}
syncEffects()
}
}
}
const connect = () => { const connect = () => {
if (!alive) return if (!alive) return
setWsStatus('connecting') setWsStatus('connecting')
ws = new WebSocket(url) ws = new WebSocket(url)
ws.onopen = () => { // ---- helper: trace-objekt in NadePath mergen + Effekte erzeugen/entfernen
setWsStatus('open') const upsertNadeTrace = (tr: any) => {
console.info('[cs2-ws] connected →', url) const now = Date.now()
const id = String(tr?.id ?? tr?.guid ?? tr?.entityId ?? tr?.grenadeId ?? '')
const kind = String(tr?.nade ?? tr?.kind ?? tr?.type ?? tr?.weapon ?? '').toLowerCase()
if (!id) return
const addPoint = (pt: any) => {
const px = Number(pt?.x ?? pt?.pos?.x)
const py = Number(pt?.y ?? pt?.pos?.y)
const pz = Number(pt?.z ?? pt?.pos?.z ?? 0)
if (!Number.isFinite(px) || !Number.isFinite(py)) return
const t = Number(pt?.t ?? tr?.t ?? now)
const s0 = Number(pt?.s ?? pt?.S ?? tr?.s ?? tr?.S)
let cur = nadePathsRef.current.get(id)
if (!cur) cur = { id, kind, points: [], startedMs: now }
const last = cur.points[cur.points.length - 1]
const seg = Number.isFinite(s0) ? s0 : (last?.s ?? 0)
// einfache Dedupe (Distanz + Zeit)
const tooClose = last && Math.hypot(last.x - px, last.y - py) <= 1
const sameTime = last && (last.t ?? 0) === t
if (tooClose && sameTime) return
cur.points.push({ x: px, y: py, z: pz, t, s: seg })
nadePathsRef.current.set(id, cur)
} }
// Punkte anhängen (einzeln oder als Liste)
if (Array.isArray(tr?.points)) tr.points.forEach(addPoint)
else if (tr?.pos || (tr?.x != null && tr?.y != null)) addPoint(tr.pos ?? tr)
// Status auswerten
const state = String(tr?.state ?? tr?.sub ?? tr?.phase ?? '').toLowerCase()
const markEnded = () => {
const cur = nadePathsRef.current.get(id)
if (cur && !cur.endedMs) { cur.endedMs = now; nadePathsRef.current.set(id, cur) }
}
// Detonation -> Pfad beenden + Effekt starten
if (state === 'detonate' || state === 'detonated' || tr?.detonate || tr?.done) {
markEnded()
const pos = tr?.pos ?? tr
if (kind.includes('smoke')) addEffect('smoke', { ...pos, id })
if (kind.includes('molotov') || kind.includes('incen') || kind.includes('fire')) addEffect('fire', { ...pos, id })
}
// Ende (explizit) -> Effekt entfernen (Smoke mit Fade, Fire sofort wie gehabt)
if (state === 'end' || state === 'expired') {
if (kind.includes('smoke')) removeEffect('smoke', { id })
if (kind.includes('molotov') || kind.includes('incen') || kind.includes('fire')) removeEffect('fire', { id })
markEnded()
}
}
ws.onopen = () => { setWsStatus('open'); console.info('[cs2-ws] connected →', url) }
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
let msg: any = null let msg: any = null
try { msg = JSON.parse(ev.data as string) } catch { /* ignore raw */ } try { msg = JSON.parse(ev.data as string) } catch {}
// --- NEU: Map-Meldung direkt auswerten --- const tickTs = Number(msg?.t ?? Date.now())
if (msg && msg.type === 'map' && typeof msg.name === 'string') {
// msg.name z.B. "de_dust2" const handleEvent = (e: any) => {
setWsMapKey(msg.name) if (!e) return
// 1) Map-Event
if (e.type === 'map' || e.type === 'level' || e.map) {
const key = e.name || e.map || e.level
if (typeof key === 'string' && key) setActiveMapKey(key)
return return
} }
// Spieler-Formate // 2) Effekte -> NICHT als Player behandeln
const candidates: any[] = [] if (e.type === 'smoke' || e.type === 'fire') {
if (Array.isArray(msg)) candidates.push(...msg) const t = e.type as EffectType
else if (msg?.type === 'players' && Array.isArray(msg.players)) candidates.push(...msg.players) if (e.state === 'start') addEffect(t, e)
else if (Array.isArray(msg?.players)) candidates.push(...msg.players) else if (e.state === 'end') removeEffect(t, e)
else if (Array.isArray(msg?.telemetry?.players)) candidates.push(...msg.telemetry.players) return
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()
if (e.type === 'nade') {
const id = String(e.id ?? '')
const kind = String(e.nade ?? e.weapon ?? '')
if (!id) return
if (e.sub === 'thrown') {
const p = e.throwPos ?? e.pos ?? {}
const x = Number(p.x), y = Number(p.y), z = Number(p.z ?? 0)
if (!Number.isFinite(x) || !Number.isFinite(y)) return
nadePathsRef.current.set(id, {
id, kind,
points: [{ x, y, z, t: e.t }],
startedMs: Date.now()
})
syncNadePaths()
return
}
if (e.sub === 'path') {
const cur = nadePathsRef.current.get(id)
const pts = Array.isArray(e.points) ? e.points as NadePoint[] : []
if (!cur) {
if (pts.length) {
nadePathsRef.current.set(id, { id, kind, points: pts.slice(0, 200), startedMs: Date.now() })
syncNadePaths()
}
return
}
// Punkte anhängen (ein wenig deduplizieren)
for (const pt of pts) {
const x = Number(pt.x), y = Number(pt.y)
if (!Number.isFinite(x) || !Number.isFinite(y)) continue
const last = cur.points[cur.points.length - 1]
if (!last || Math.hypot(last.x - x, last.y - y) > 1) cur.points.push({ x, y, z: pt.z, t: pt.t })
}
// Länge begrenzen
if (cur.points.length > 300) cur.points.splice(0, cur.points.length - 300)
nadePathsRef.current.set(id, cur)
syncNadePaths()
return
}
if (e.sub === 'detonate') {
const cur = nadePathsRef.current.get(id)
if (cur && !cur.endedMs) {
// Endpunkt (falls vorhanden) hinzufügen
const p = e.pos ?? {}
const x = Number(p.x), y = Number(p.y)
if (Number.isFinite(x) && Number.isFinite(y)) cur.points.push({ x, y, z: Number(p.z ?? 0), t: e.t })
cur.endedMs = Date.now()
nadePathsRef.current.set(id, cur)
syncNadePaths()
}
return
} }
} }
ws.onerror = (err) => { // 3) Player-ähnliche Events
setWsStatus('error') if (!(e.steamId || e.steam_id || e.pos || e.position)) return
console.error('[cs2-ws] error:', err)
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
if (!id) return
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates
const x = Number(e.x ?? pos?.x ?? (Array.isArray(pos) ? pos?.[0] : undefined))
const y = Number(e.y ?? pos?.y ?? (Array.isArray(pos) ? pos?.[1] : undefined))
const z = Number(e.z ?? pos?.z ?? (Array.isArray(pos) ? pos?.[2] : 0))
if (!Number.isFinite(x) || !Number.isFinite(y)) return
const prev = playersRef.current.get(id)
const rawYaw = e.yaw ?? e.ang?.y ?? e.angles?.y ?? e.rotation?.yaw ?? e.view?.yaw
const yawDeg = deriveYawDeg(rawYaw, prev, x, y)
const p: PlayerState = {
id,
name: e.name,
team: mapTeam(e.team),
x, y, z,
yaw: yawDeg,
alive: e.alive,
}
playersRef.current.set(id, p)
} }
ws.onclose = (ev) => { if (Array.isArray(msg)) {
console.warn('[cs2-ws] closed:', ev.code, ev.reason) for (const e of msg) handleEvent(e)
} else if (msg?.type === 'tick' && Array.isArray(msg.players)) {
for (const p of msg.players) handleEvent(p)
} else {
handleEvent(msg)
}
if (msg?.nades?.trace && Array.isArray(msg.nades.trace)) {
for (const tr of msg.nades.trace) upsertNadeTrace(tr)
syncNadePaths() // sichtbaren State aktualisieren
}
if (Array.isArray(msg?.nades?.active)) {
for (const a of msg.nades.active) {
const id = String(a?.id ?? '')
if (!id) continue
const kind = String(a?.nade ?? a?.kind ?? '').toLowerCase()
const p = a.pos ?? {}
const x = Number(p.x), y = Number(p.y), z = Number(p.z ?? 0)
if (!Number.isFinite(x) || !Number.isFinite(y)) continue
let cur = nadePathsRef.current.get(id)
if (!cur) cur = { id, kind, points: [], startedMs: Date.now() }
else if (!cur.kind && kind) cur.kind = kind
const last = cur.points[cur.points.length - 1]
const seg = last?.s ?? 0
// IMMER anhängen (pro Tick ein Punkt)
cur.points.push({ x, y, z, t: tickTs, s: seg })
if (cur.points.length > 500) cur.points.splice(0, cur.points.length - 500)
nadePathsRef.current.set(id, cur)
}
syncNadePaths() // sofort zeichnen
}
// Spieler-Flush (wenn sich etwas geändert hat)
scheduleFlush()
}
ws.onerror = (e) => { setWsStatus('error'); console.error('[cs2-ws] error:', e) }
ws.onclose = () => {
setWsStatus('closed') setWsStatus('closed')
if (alive) retryTimer = window.setTimeout(connect, 2000) if (alive) retry = window.setTimeout(connect, 2000)
} }
} }
connect() connect()
return () => { return () => {
alive = false alive = false
if (retryTimer) window.clearTimeout(retryTimer) if (retry) window.clearTimeout(retry)
try { ws?.close(1000, 'radar unmounted') } catch {} try { ws?.close(1000, 'radar unmounted') } catch {}
} }
}, []) }, [])
// Fallback-Map aus dem Voting (erste gespielte) // Automatisches Aufräumen von abgelaufenen Effekten (TTL)
const votedFirstMapKey = useMemo(() => { useEffect(() => {
const chosen = (data?.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map) const iv = window.setInterval(() => {
return chosen[0]?.map ?? null const now = Date.now()
}, [data]) let changed = false
let haveAny = false
// Aktive Map: WS > Voting for (const [k, e] of effectsRef.current) {
const activeMapKey = wsMapKey ?? votedFirstMapKey haveAny = true
// wenn beendet & Fade vorbei -> löschen
if (e.ending && e.fadeUntil && now >= e.fadeUntil) {
effectsRef.current.delete(k)
changed = true
continue
}
// Sicherheit: TTL nur anwenden, wenn nicht bereits im Fade
if (!e.ending && now - e.startMs > e.ttlMs) {
effectsRef.current.delete(k)
changed = true
}
}
// Label // --- Nade-Pfade nach TTL entfernen ---
const mapLabel = useMemo(() => { for (const [k, np] of nadePathsRef.current) {
if (!activeMapKey) return 'Unbekannte Map' if (np.endedMs && Date.now() - np.endedMs > NADE_PATH_TTL) {
return data?.mapVisuals?.[activeMapKey]?.label ?? activeMapKey nadePathsRef.current.delete(k)
}, [data, activeMapKey]) changed = true
}
}
if (changed) setNadePaths(Array.from(nadePathsRef.current.values()))
// Radar-Datei(en) für aktive Map // Repaint erzwingen, damit Fade-Opacity sichtbar animiert
const { folderKey, candidates } = useMemo(() => { if (changed || haveAny) {
if (!activeMapKey) return { folderKey: null as string | null, candidates: [] as string[] } setEffects(Array.from(effectsRef.current.values()))
const key = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey }
const base = `/assets/img/radar/${activeMapKey}` // Ordner ist [mapKey], z.B. de_dust2 }, 100) // ~10 FPS reicht für sanftes Fade
const list = [ return () => window.clearInterval(iv)
`${base}/de_${key}_radar_psd.png`, }, [])
`${base}/de_${key}_lower_radar_psd.png`,
`${base}/de_${key}_v1_radar_psd.png`, // ---- 3) Overview laden (JSON ODER Valve-KV)
`${base}/de_${key}_radar.png`, const [overview, setOverview] = useState<Overview | null>(null)
function overviewCandidates(mapKey: string) {
const base = mapKey
return [
`/assets/resource/overviews/${base}.json`,
`/assets/resource/overviews/${base}_lower.json`,
`/assets/resource/overviews/${base}_v1.json`,
`/assets/resource/overviews/${base}_v2.json`,
`/assets/resource/overviews/${base}_s2.json`,
] ]
return { folderKey: key, candidates: list } }
const parseOverviewJson = (j: any): Overview | null => {
const posX = Number(j?.posX ?? j?.pos_x)
const posY = Number(j?.posY ?? j?.pos_y)
const scale = Number(j?.scale)
const rotate = Number(j?.rotate ?? 0)
if (![posX, posY, scale].every(Number.isFinite)) return null
return { posX, posY, scale, rotate }
}
const parseValveKvOverview = (txt: string): Overview | null => {
const clean = txt.replace(/\/\/.*$/gm, '')
const pick = (k: string) => { const m = clean.match(new RegExp(`"\\s*${k}\\s*"\\s*"([^"]+)"`)); return m ? Number(m[1]) : NaN }
const posX = pick('pos_x'), posY = pick('pos_y'), scale = pick('scale')
const r = pick('rotate'); const rotate = Number.isFinite(r) ? r : 0
if (![posX, posY, scale].every(Number.isFinite)) return null
return { posX, posY, scale, rotate }
}
useEffect(() => {
let cancel = false
;(async () => {
if (!activeMapKey) { setOverview(null); return }
for (const path of overviewCandidates(activeMapKey)) {
try {
const res = await fetch(path, { cache: 'no-store' })
if (!res.ok) continue
const txt = await res.text()
let ov: Overview | null = null
try { ov = parseOverviewJson(JSON.parse(txt)) } catch { ov = parseValveKvOverview(txt) }
if (ov && !cancel) { setOverview(ov); return }
} catch {}
}
if (!cancel) setOverview(null)
})()
return () => { cancel = true }
}, [activeMapKey])
// ---- 4) Radarbild-Pfade
const mapLabel = useMemo(() => {
if (activeMapKey && voteData?.mapVisuals?.[activeMapKey]?.label)
return voteData.mapVisuals[activeMapKey].label
if (activeMapKey) return activeMapKey.replace(/^de_/, '').replace(/_/g, ' ').toUpperCase()
return 'Unbekannte Map'
}, [activeMapKey, voteData?.mapVisuals])
const { folderKey, imageCandidates } = useMemo(() => {
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey
const base = `/assets/img/radar/${activeMapKey}`
return {
folderKey: short,
imageCandidates: [
`${base}/de_${short}_radar_psd.png`,
`${base}/de_${short}_lower_radar_psd.png`,
`${base}/de_${short}_v1_radar_psd.png`,
`${base}/de_${short}_radar.png`,
],
}
}, [activeMapKey]) }, [activeMapKey])
const [srcIdx, setSrcIdx] = useState(0) const [srcIdx, setSrcIdx] = useState(0)
useEffect(() => { setSrcIdx(0) }, [folderKey]) useEffect(() => { setSrcIdx(0) }, [folderKey])
const currentSrc = candidates[srcIdx] const currentSrc = imageCandidates[srcIdx]
// Bildgröße für SVG-Overlay // ---- 5) Bildgröße
const [imgSize, setImgSize] = useState<{w: number, h: number} | null>(null) const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null)
// Welt→Bild (0/0/0 = Bildmitte) // ---- 6) Welt→Pixel + Einheiten→Pixel -------------------------------------
const DEFAULT_WORLD_RADIUS = 4096 type Mapper = (xw: number, yw: number) => { x: number; y: number }
const pxPerUnit = useMemo(() => {
if (!imgSize) return 0.1 const worldToPx: Mapper = useMemo(() => {
const spanPx = Math.min(imgSize.w, imgSize.h) if (!imgSize || !overview) {
return spanPx / (2 * DEFAULT_WORLD_RADIUS) return (xw, yw) => {
}, [imgSize])
const worldToPx = (x: number, y: number) => {
if (!imgSize) return { x: 0, y: 0 } if (!imgSize) return { x: 0, y: 0 }
const cx = imgSize.w / 2, cy = imgSize.h / 2 const R = 4096
return { x: cx + x * pxPerUnit, y: cy - y * pxPerUnit } const span = Math.min(imgSize.w, imgSize.h)
const k = span / (2 * R)
return { x: imgSize.w / 2 + xw * k, y: imgSize.h / 2 - yw * k }
} }
}
const { posX, posY, scale } = overview
const rotDeg = overview.rotate ?? 0
const w = imgSize.w, h = imgSize.h
const cx = w / 2, cy = h / 2
const bases: ((xw: number, yw: number) => { x: number; y: number })[] = [
(xw, yw) => ({ x: (xw - posX) / scale, y: (posY - yw) / scale }),
(xw, yw) => ({ x: (posX - xw) / scale, y: (posY - yw) / scale }),
(xw, yw) => ({ x: (xw - posX) / scale, y: (yw - posY) / scale }),
(xw, yw) => ({ x: (posX - xw) / scale, y: (yw - posY) / scale }),
]
const rotSigns = [1, -1]
const candidates: Mapper[] = []
for (const base of bases) {
for (const s of rotSigns) {
const theta = (rotDeg * s * Math.PI) / 180
candidates.push((xw, yw) => {
const p = base(xw, yw)
if (rotDeg === 0) return p
const dx = p.x - cx, dy = p.y - cy
const xr = dx * Math.cos(theta) - dy * Math.sin(theta)
const yr = dx * Math.sin(theta) + dy * Math.cos(theta)
return { x: cx + xr, y: cy + yr }
})
}
}
if (players.length === 0) return candidates[0]
const score = (mapFn: Mapper) => {
let inside = 0
for (const p of players) {
const { x, y } = mapFn(p.x, p.y)
if (Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0 && x <= w && y <= h) inside++
}
return inside
}
let best = candidates[0], bestScore = -1
for (const m of candidates) {
const s = score(m)
if (s > bestScore) { bestScore = s; best = m }
}
return best
}, [imgSize, overview, players])
const unitsToPx = useMemo(() => {
if (!imgSize) return (u: number) => u
if (overview) {
const scale = overview.scale // world units per pixel
return (u: number) => u / scale
}
const R = 4096
const span = Math.min(imgSize.w, imgSize.h)
const k = span / (2 * R)
return (u: number) => u * k
}, [imgSize, overview])
// ---- Status-Badge
const WsDot = ({ status }: { status: typeof wsStatus }) => { const WsDot = ({ status }: { status: typeof wsStatus }) => {
const color = const color =
status === 'open' ? 'bg-green-500' : status === 'open' ? 'bg-green-500' :
@ -241,12 +706,43 @@ export default function LiveRadar({ matchId }: Props) {
) )
} }
function splitSegments(points: NadePoint[], jumpWorld = 64): NadePoint[][] {
const out: NadePoint[][] = []
let cur: NadePoint[] = []
let lastS = points.length ? (points[0].s ?? 0) : 0
let last: NadePoint | null = null
for (const p of points) {
const s = p.s ?? lastS
const jump = last ? Math.hypot(p.x - last.x, p.y - last.y) > jumpWorld : false
const breakHere = (s !== lastS) || jump
if (breakHere) {
if (cur.length >= 2) out.push(cur)
cur = [p]
} else {
cur.push(p)
}
last = p
lastS = s
}
if (cur.length >= 2) out.push(cur)
return out
}
// ---- Render
return ( return (
<div className="p-4"> <div className="p-4">
<div ref={headerRef} 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> <h2 className="text-xl font-semibold">Live Radar</h2>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-sm opacity-80">{mapLabel}</div> <div className="text-sm opacity-80">
{activeMapKey
? (voteData?.mapVisuals?.[activeMapKey]?.label ??
activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase())
: '—'}
</div>
<WsDot status={wsStatus} /> <WsDot status={wsStatus} />
</div> </div>
</div> </div>
@ -257,7 +753,7 @@ export default function LiveRadar({ matchId }: Props) {
<div className="p-4 text-red-600">{error}</div> <div className="p-4 text-red-600">{error}</div>
) : !activeMapKey ? ( ) : !activeMapKey ? (
<div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100"> <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. Keine Map erkannt.
</div> </div>
) : ( ) : (
<div className="w-full"> <div className="w-full">
@ -270,7 +766,7 @@ export default function LiveRadar({ matchId }: Props) {
<img <img
key={currentSrc} key={currentSrc}
src={currentSrc} src={currentSrc}
alt={mapLabel} alt={activeMapKey}
className="block h-auto max-w-full" className="block h-auto max-w-full"
style={{ maxHeight: maxImgHeight ?? undefined }} style={{ maxHeight: maxImgHeight ?? undefined }}
onLoad={(e) => { onLoad={(e) => {
@ -278,33 +774,141 @@ export default function LiveRadar({ matchId }: Props) {
setImgSize({ w: img.naturalWidth, h: img.naturalHeight }) setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
}} }}
onError={() => { onError={() => {
if (srcIdx < candidates.length - 1) setSrcIdx(i => i + 1) if (srcIdx < imageCandidates.length - 1) setSrcIdx(i => i + 1)
else setError('Radar-Grafik nicht gefunden.') else setError('Radar-Grafik nicht gefunden.')
}} }}
/> />
{imgSize && ( {imgSize && (
<svg <svg
className="absolute inset-0 w-full h-full pointer-events-none" className="absolute inset-0 w-full h-full pointer-events-none"
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`} viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
> >
{players.map((p) => { {/* SVG-Defs für Filter */}
const { x, y } = worldToPx(p.x, p.y) <defs>
const dirLen = Math.max(18, Math.min(imgSize.w, imgSize.h) * 0.025) <filter id="smoke-blur" x="-50%" y="-50%" width="200%" height="200%">
const rad = (p.yaw * Math.PI) / 180 <feGaussianBlur stdDeviation="8" />
const dx = Math.cos(rad) * dirLen </filter>
const dy = -Math.sin(rad) * dirLen <filter id="fire-blur" x="-50%" y="-50%" width="200%" height="200%">
const r = Math.max(4, Math.min(imgSize.w, imgSize.h) * 0.008) <feGaussianBlur stdDeviation="3" />
const color = p.team === 'CT' ? '#3b82f6' : p.team === 'T' ? '#f59e0b' : '#10b981' </filter>
const strokeW = Math.max(1.5, r * 0.35) <radialGradient id="fire-grad" cx="50%" cy="40%" r="60%">
<stop offset="0%" stopColor="#ffd166" stopOpacity="0.95" />
<stop offset="60%" stopColor="#f97316" stopOpacity="0.7" />
<stop offset="100%" stopColor="#ef4444" stopOpacity="0.0" />
</radialGradient>
</defs>
{/* --- Effekte unter den Spielern (SVG-Icon-basiert) --- */}
{effects.map(e => {
const { x, y } = worldToPx(e.x, e.y)
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
const R_WORLD = e.type === 'smoke' ? 170 : 110
const halfPx = Math.max(12, unitsToPx(R_WORLD))
const sBase = (halfPx * 2) / 640
const s = sBase * (e.type === 'smoke' ? SMOKE_ICON_SCALE : FIRE_ICON_SCALE)
const baseT = `translate(${x},${y}) scale(${s}) translate(-320,-320)`
let fadeAlpha = 1
if (e.type === 'smoke' && e.fadeUntil) {
const remain = Math.max(0, e.fadeUntil - Date.now())
fadeAlpha = Math.min(1, remain / SMOKE_FADE_MS)
}
if (e.type === 'smoke') {
return (
<g filter="url(#smoke-blur)" opacity={SMOKE_GROUP_OPACITY * fadeAlpha} transform={baseT}>
<path d={SMOKE_PATH} fill="#949494" fillOpacity={SMOKE_FILL_OPACITY} />
</g>
)
}
return ( return (
<g key={p.id}> <g filter="url(#fire-blur)" opacity={FIRE_GROUP_OPACITY} transform={baseT}>
<line x1={x} y1={y} x2={x + dx} y2={y + dy} stroke={color} strokeWidth={strokeW} strokeLinecap="round" opacity={p.alive === false ? 0.5 : 1} /> <path d={FIRE_PATH} fill="url(#fire-grad)" />
<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> </g>
) )
})} })}
{/* --- Spieler darüber --- */}
{players
.filter(p => p.team === 'CT' || p.team === 'T') // <— nur CT/T
.map((p) => {
const A = worldToPx(p.x, p.y)
const base = Math.min(imgSize.w, imgSize.h)
const r = Math.max(4, base * 0.008)
const dirLenPx = Math.max(18, base * 0.025)
const stroke = '#fff'
const strokeW = Math.max(1.5, r * 0.35)
const color = p.team === 'CT' ? '#3b82f6' : '#f59e0b'
const yawRad = (p.yaw * Math.PI) / 180
const STEP_WORLD = 200
const B = worldToPx(
p.x + Math.cos(yawRad) * STEP_WORLD,
p.y + Math.sin(yawRad) * STEP_WORLD
)
let dxp = B.x - A.x, dyp = B.y - A.y
const cur = Math.hypot(dxp, dyp) || 1
dxp *= dirLenPx / cur
dyp *= dirLenPx / cur
return (
<g key={p.id}>
<line x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp}
stroke={color} strokeWidth={strokeW} strokeLinecap="round"
opacity={p.alive === false ? 0.5 : 1}/>
<circle cx={A.x} cy={A.y} r={r}
fill={color} stroke={stroke}
strokeWidth={Math.max(1, r*0.3)}
opacity={p.alive === false ? 0.6 : 1}/>
</g>
)
})}
{/* --- Nade-Pfade (unter Effekten & Spielern) --- */}
<g opacity={0.95}>
{nadePaths.map(np => {
const col = nadeColor(np.kind)
const wBase = Math.min(imgSize.w, imgSize.h)
const strokeW = Math.max(2, wBase * 0.004)
const dotR = Math.max(1.5, wBase * 0.0025)
let alpha = 1
if (np.endedMs) alpha = Math.max(0, 1 - (Date.now() - np.endedMs) / NADE_PATH_TTL)
const pts = np.points
.map(p => worldToPx(p.x, p.y))
.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
if (pts.length === 0) return null
const d = pts.length >= 2
? `M ${pts[0].x} ${pts[0].y} ` + pts.slice(1).map(p => `L ${p.x} ${p.y}`).join(' ')
: null
return (
<g key={np.id} opacity={alpha}>
{d && (
<path
d={d}
fill="none"
stroke={col}
strokeWidth={strokeW}
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* Punkte anzeigen, damit sofort etwas sichtbar ist */}
{pts.map((p, i) => (
<circle key={`${np.id}:pt:${i}`} cx={p.x} cy={p.y} r={dotR} fill={col} opacity={0.9} />
))}
</g>
)
})}
</g>
</svg> </svg>
)} )}
</> </>