updated player angle

This commit is contained in:
Linrador 2025-08-19 22:41:45 +02:00
parent c10bd74b70
commit 02b2a8efc8

View File

@ -4,28 +4,69 @@
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; // ---------- Konfiguration (UI & Verhalten) ----------
function normalizeDeg(d: number) { d = d % 360; return d < 0 ? d + 360 : d; } const UI = {
function toYawDegMaybe(raw: any): number | null { player: {
const v = Number(raw); minRadiusPx: 4,
if (!Number.isFinite(v)) return null; radiusRel: 0.008, // Radius relativ zur kleineren Bildkante
if (Math.abs(v) <= 2 * Math.PI + 1e-3) return v * RAD2DEG; // radians dirLenRel: 0.70, // Anteil des Radius, Linie bleibt im Kreis
return v; // degrees dirMinLenPx: 6,
} lineWidthRel: 0.25, // Linienbreite relativ zum Radius
function deriveYawDeg(raw: any, prev: PlayerState | undefined, x: number, y: number): number { stroke: '#ffffff',
const fromRaw = toYawDegMaybe(raw); fillCT: '#3b82f6',
if (fromRaw != null && Math.abs(fromRaw) > 1e-6 && Math.abs(fromRaw) < 1e6) return normalizeDeg(fromRaw); fillT: '#f59e0b',
if (prev) { // 'auto' = automatisch kontrastierend zum Kreis, sonst fixe Farbe wie '#fff'
const dx = x - prev.x, dy = y - prev.y; dirColor: 'auto' as 'auto' | string,
if (Math.hypot(dx, dy) > 1) return normalizeDeg(Math.atan2(dy, dx) * RAD2DEG); },
if (Number.isFinite(prev.yaw)) return prev.yaw; effects: {
smokeIconScale: 1.6,
fireIconScale: 1.45,
smokeOpacity: 0.95,
smokeFillOpacity: 0.70,
fireOpacity: 1,
smokeFadeMs: 3000,
} }
return 0;
} }
const RAD2DEG = 180 / Math.PI
function normalizeDeg(d: number) {
d = d % 360
return d < 0 ? d + 360 : d
}
function shortestAngleDeltaDeg(a: number, b: number) {
// delta in [-180, 180)
return ((b - a + 540) % 360) - 180
}
function lerpAngleDeg(a: number, b: number, t: number) {
return normalizeDeg(a + shortestAngleDeltaDeg(a, b) * t)
}
// Yaw-Quelle parsen (Server liefert Grad)
function toYawDegMaybe(raw: any): number | null {
const v = Number(raw)
return Number.isFinite(v) ? v : null
}
// Fallback, wenn keine yaw übermittelt wird
function deriveYawDeg(raw: any, prev: PlayerState | undefined, x: number, y: number): number {
const fromRaw = toYawDegMaybe(raw)
if (fromRaw != null) 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 { function mapTeam(t: any): 'T' | 'CT' | string {
if (t === 2 || t === 'T' || t === 't') return 'T'; if (t === 2 || t === 'T' || t === 't') return 'T'
if (t === 3 || t === 'CT' || t === 'ct') return 'CT'; if (t === 3 || t === 'CT' || t === 'ct') return 'CT'
return String(t ?? ''); return String(t ?? '')
} }
type Props = { matchId: string } type Props = { matchId: string }
@ -62,16 +103,15 @@ type Effect = {
z: number z: number
startMs: number startMs: number
ttlMs: number ttlMs: number
// neu:
ending?: boolean ending?: boolean
fadeUntil?: number fadeUntil?: number
} }
// --- Nade-Pfade ------------------------------------------------------------- // --- Nade-Pfade --------------------------------------------------------------
type NadePoint = { x: number; y: number; z?: number; t?: number; s?: number } type NadePoint = { x: number; y: number; z?: number; t?: number; s?: number }
type NadePath = { type NadePath = {
id: string id: string
kind: string // 'smoke' | 'flash' | 'he' | 'molotov' | ... kind: string
points: NadePoint[] points: NadePoint[]
startedMs: number startedMs: number
endedMs?: number endedMs?: number
@ -81,14 +121,24 @@ const NADE_PATH_TTL = 6000 // Pfad noch 6s nach Detonation zeigen
function nadeColor(kind: string) { function nadeColor(kind: string) {
const k = String(kind || '').toLowerCase() const k = String(kind || '').toLowerCase()
if (k.includes('smoke')) return '#94a3b8' // smoke: slate-grau if (k.includes('smoke')) return '#94a3b8'
if (k.includes('flash')) return '#fbbf24' // flash: gelb if (k.includes('flash')) return '#fbbf24'
if (k.includes('molotov') || k.includes('incen') || k === 'fire') return '#f97316' // molly if (k.includes('molotov') || k.includes('incen') || k === 'fire') return '#f97316'
if (k.includes('he') || k.includes('frag')) return '#ef4444' // HE if (k.includes('he') || k.includes('frag')) return '#ef4444'
if (k.includes('decoy')) return '#22c55e' // decoy if (k.includes('decoy')) return '#22c55e'
return '#a3a3a3' return '#a3a3a3'
} }
function contrastStroke(hex: string) {
const h = hex.replace('#','')
const r = parseInt(h.slice(0,2),16)/255
const g = parseInt(h.slice(2,4),16)/255
const b = parseInt(h.slice(4,6),16)/255
const toL = (c:number) => (c<=0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4))
const L = 0.2126*toL(r) + 0.7152*toL(g) + 0.0722*toL(b)
return L > 0.6 ? '#111111' : '#ffffff'
}
export default function LiveRadar({ matchId }: Props) { export default function LiveRadar({ matchId }: Props) {
// ---- MapVote (Backup) // ---- MapVote (Backup)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -116,29 +166,32 @@ export default function LiveRadar({ matchId }: Props) {
} }
}, []) }, [])
// SMOKE + FIRE: // ---- Effekt-SVG-Paths
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 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 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 // ---- Nade-Pfade & Effekte -------------------------------------------------
// 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 nadePathsRef = useRef<Map<string, NadePath>>(new Map())
const [nadePaths, setNadePaths] = useState<NadePath[]>([]) const [nadePaths, setNadePaths] = useState<NadePath[]>([])
const syncNadePaths = () => setNadePaths(Array.from(nadePathsRef.current.values())) const syncNadePaths = () => setNadePaths(Array.from(nadePathsRef.current.values()))
const effectsRef = useRef<Map<string, Effect>>(new Map())
const [effects, setEffects] = useState<Effect[]>([])
const syncEffects = () => setEffects(Array.from(effectsRef.current.values()))
// ---- Spieler (throttled) --------------------------------------------------
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)
}
// ---- 1) MapVote laden // ---- MapVote laden --------------------------------------------------------
useEffect(() => { useEffect(() => {
let cancel = false let cancel = false
;(async () => { ;(async () => {
@ -161,37 +214,7 @@ export default function LiveRadar({ matchId }: Props) {
return () => { cancel = true } return () => { cancel = true }
}, [matchId]) }, [matchId])
// ---- Spieler-Overlay (ge-throttled) // ---- Aktive Map bestimmen -------------------------------------------------
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)
}
// ---- 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 {
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] : 0))
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, yaw, alive: p.alive }
}
// ---- Aktive Map (WS hat Vorrang)
const voteMapKey = useMemo(() => { const voteMapKey = useMemo(() => {
const chosen = (voteData?.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map) const chosen = (voteData?.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
return chosen[0]?.map ?? null return chosen[0]?.map ?? null
@ -202,7 +225,7 @@ export default function LiveRadar({ matchId }: Props) {
if (!activeMapKey && voteMapKey) setActiveMapKey(voteMapKey) if (!activeMapKey && voteMapKey) setActiveMapKey(voteMapKey)
}, [voteMapKey, activeMapKey]) }, [voteMapKey, activeMapKey])
// ---- 2) WS verbinden: Map + Players + Effekte // ---- WebSocket verbinden --------------------------------------------------
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
@ -216,14 +239,14 @@ export default function LiveRadar({ matchId }: Props) {
let ws: WebSocket | null = null let ws: WebSocket | null = null
let retry: number | null = null let retry: number | null = null
// Hilfsfunktionen für Effekte // Effekte hinzufügen/entfernen
const addEffect = (type: EffectType, m: any) => { const addEffect = (type: EffectType, m: any) => {
const pos = m.pos ?? m.position 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) 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 if (!Number.isFinite(x) || !Number.isFinite(y)) return
const serverId = m.id ?? m.entityId ?? m.grenadeId ?? m.guid 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 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) const ttlMs = type === 'smoke' ? 19000 : 7000
effectsRef.current.set(id, { id, type, x, y, z, startMs: Date.now(), ttlMs }) effectsRef.current.set(id, { id, type, x, y, z, startMs: Date.now(), ttlMs })
syncEffects() syncEffects()
} }
@ -235,12 +258,10 @@ export default function LiveRadar({ matchId }: Props) {
const e = effectsRef.current.get(key) const e = effectsRef.current.get(key)
if (e) { if (e) {
if (type === 'smoke') { if (type === 'smoke') {
// statt löschen: Fade markieren
e.ending = true e.ending = true
e.fadeUntil = Date.now() + SMOKE_FADE_MS e.fadeUntil = Date.now() + UI.effects.smokeFadeMs
effectsRef.current.set(key, e) effectsRef.current.set(key, e)
} else { } else {
// fire weiterhin sofort entfernen
effectsRef.current.delete(key) effectsRef.current.delete(key)
} }
syncEffects() syncEffects()
@ -248,9 +269,8 @@ export default function LiveRadar({ matchId }: Props) {
return return
} }
// Fallback: nächster gleicher Typ in der Nähe // Fallback: nächster gleicher Typ in der Nähe (ohne serverId)
const pos = m.pos ?? m.position const pos = m.pos ?? m.position
const x = Number(m.x ?? pos?.x), y = Number(m.y ?? pos?.y) const x = Number(m.x ?? pos?.x), y = Number(m.y ?? pos?.y)
if (Number.isFinite(x) && Number.isFinite(y)) { if (Number.isFinite(x) && Number.isFinite(y)) {
let bestKey: string | null = null, bestD = Infinity let bestKey: string | null = null, bestD = Infinity
@ -263,7 +283,7 @@ export default function LiveRadar({ matchId }: Props) {
if (type === 'smoke') { if (type === 'smoke') {
const e = effectsRef.current.get(bestKey)! const e = effectsRef.current.get(bestKey)!
e.ending = true e.ending = true
e.fadeUntil = Date.now() + SMOKE_FADE_MS e.fadeUntil = Date.now() + UI.effects.smokeFadeMs
effectsRef.current.set(bestKey, e) effectsRef.current.set(bestKey, e)
} else { } else {
effectsRef.current.delete(bestKey) effectsRef.current.delete(bestKey)
@ -273,19 +293,14 @@ export default function LiveRadar({ matchId }: Props) {
} }
} }
const connect = () => { // Nade-Trace verarbeiten
if (!alive) return const upsertNadeTrace = (tr: any) => {
setWsStatus('connecting') const now = Date.now()
ws = new WebSocket(url) 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
// ---- helper: trace-objekt in NadePath mergen + Effekte erzeugen/entfernen const addPoint = (pt: any) => {
const upsertNadeTrace = (tr: any) => {
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 px = Number(pt?.x ?? pt?.pos?.x)
const py = Number(pt?.y ?? pt?.pos?.y) const py = Number(pt?.y ?? pt?.pos?.y)
const pz = Number(pt?.z ?? pt?.pos?.z ?? 0) const pz = Number(pt?.z ?? pt?.pos?.z ?? 0)
@ -300,7 +315,6 @@ export default function LiveRadar({ matchId }: Props) {
const last = cur.points[cur.points.length - 1] const last = cur.points[cur.points.length - 1]
const seg = Number.isFinite(s0) ? s0 : (last?.s ?? 0) 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 tooClose = last && Math.hypot(last.x - px, last.y - py) <= 1
const sameTime = last && (last.t ?? 0) === t const sameTime = last && (last.t ?? 0) === t
if (tooClose && sameTime) return if (tooClose && sameTime) return
@ -309,52 +323,51 @@ export default function LiveRadar({ matchId }: Props) {
nadePathsRef.current.set(id, cur) nadePathsRef.current.set(id, cur)
} }
// Punkte anhängen (einzeln oder als Liste) if (Array.isArray(tr?.points)) tr.points.forEach(addPoint)
if (Array.isArray(tr?.points)) tr.points.forEach(addPoint) else if (tr?.pos || (tr?.x != null && tr?.y != null)) addPoint(tr.pos ?? tr)
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 state = String(tr?.state ?? tr?.sub ?? tr?.phase ?? '').toLowerCase() const markEnded = () => {
const markEnded = () => { const cur = nadePathsRef.current.get(id)
const cur = nadePathsRef.current.get(id) if (cur && !cur.endedMs) { cur.endedMs = now; nadePathsRef.current.set(id, cur) }
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) } 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 })
}
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()
}
}
const connect = () => {
if (!alive) return
setWsStatus('connecting')
ws = new WebSocket(url)
ws.onopen = () => setWsStatus('open')
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
let msg: any = null let msg: any = null
try { msg = JSON.parse(ev.data as string) } catch {} try { msg = JSON.parse(ev.data as string) } catch {}
const tickTs = Number(msg?.t ?? Date.now())
const handleEvent = (e: any) => { const handleEvent = (e: any) => {
if (!e) return if (!e) return
// 1) Map-Event // Map wechseln
if (e.type === 'map' || e.type === 'level' || e.map) { if (e.type === 'map' || e.type === 'level' || e.map) {
const key = e.name || e.map || e.level const key = e.name || e.map || e.level
if (typeof key === 'string' && key) setActiveMapKey(key) if (typeof key === 'string' && key) setActiveMapKey(key)
return return
} }
// 2) Effekte -> NICHT als Player behandeln // Effekte
if (e.type === 'smoke' || e.type === 'fire') { if (e.type === 'smoke' || e.type === 'fire') {
const t = e.type as EffectType const t = e.type as EffectType
if (e.state === 'start') addEffect(t, e) if (e.state === 'start') addEffect(t, e)
@ -362,64 +375,12 @@ export default function LiveRadar({ matchId }: Props) {
return return
} }
if (e.type === 'nade') { // Grenade-Traces
const id = String(e.id ?? '') if (e.type === 'nade' || e.kind || e.nade || e.weapon) {
const kind = String(e.nade ?? e.weapon ?? '') upsertNadeTrace(e)
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
}
} }
// 3) Player-ähnliche Events // Spieler
if (!(e.steamId || e.steam_id || e.pos || e.position)) return if (!(e.steamId || e.steam_id || e.pos || e.position)) return
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '') const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
@ -432,8 +393,20 @@ export default function LiveRadar({ matchId }: Props) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return if (!Number.isFinite(x) || !Number.isFinite(y)) return
const prev = playersRef.current.get(id) 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 rawYaw =
e.viewAngle?.yaw ??
e.view?.yaw ??
e.aim?.yaw ??
e.yaw ??
e.ang?.y ??
e.angles?.y ??
e.rotation?.yaw
const yawDegRaw = deriveYawDeg(rawYaw, prev, x, y)
// Sanftes Smoothing, damit kleine Änderungen sichtbar & stabil sind
const YAW_SMOOTH = 0.01
const yawDeg = prev ? lerpAngleDeg(prev.yaw, yawDegRaw, YAW_SMOOTH) : yawDegRaw
const p: PlayerState = { const p: PlayerState = {
id, id,
@ -454,39 +427,10 @@ export default function LiveRadar({ matchId }: Props) {
handleEvent(msg) 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() scheduleFlush()
} }
ws.onerror = (e) => { setWsStatus('error'); console.error('[cs2-ws] error:', e) } ws.onerror = () => setWsStatus('error')
ws.onclose = () => { ws.onclose = () => {
setWsStatus('closed') setWsStatus('closed')
if (alive) retry = window.setTimeout(connect, 2000) if (alive) retry = window.setTimeout(connect, 2000)
@ -501,7 +445,7 @@ export default function LiveRadar({ matchId }: Props) {
} }
}, []) }, [])
// Automatisches Aufräumen von abgelaufenen Effekten (TTL) // ---- Aufräumen (TTL) ------------------------------------------------------
useEffect(() => { useEffect(() => {
const iv = window.setInterval(() => { const iv = window.setInterval(() => {
const now = Date.now() const now = Date.now()
@ -510,40 +454,29 @@ export default function LiveRadar({ matchId }: Props) {
for (const [k, e] of effectsRef.current) { for (const [k, e] of effectsRef.current) {
haveAny = true haveAny = true
// wenn beendet & Fade vorbei -> löschen
if (e.ending && e.fadeUntil && now >= e.fadeUntil) { if (e.ending && e.fadeUntil && now >= e.fadeUntil) {
effectsRef.current.delete(k) effectsRef.current.delete(k); changed = true; continue
changed = true
continue
} }
// Sicherheit: TTL nur anwenden, wenn nicht bereits im Fade
if (!e.ending && now - e.startMs > e.ttlMs) { if (!e.ending && now - e.startMs > e.ttlMs) {
effectsRef.current.delete(k) effectsRef.current.delete(k); changed = true
changed = true
} }
} }
// --- Nade-Pfade nach TTL entfernen ---
for (const [k, np] of nadePathsRef.current) { for (const [k, np] of nadePathsRef.current) {
if (np.endedMs && Date.now() - np.endedMs > NADE_PATH_TTL) { if (np.endedMs && now - np.endedMs > NADE_PATH_TTL) {
nadePathsRef.current.delete(k) nadePathsRef.current.delete(k); changed = true
changed = true
} }
} }
if (changed) setNadePaths(Array.from(nadePathsRef.current.values())) if (changed) setNadePaths(Array.from(nadePathsRef.current.values()))
if (changed || haveAny) setEffects(Array.from(effectsRef.current.values()))
// Repaint erzwingen, damit Fade-Opacity sichtbar animiert }, 100)
if (changed || haveAny) {
setEffects(Array.from(effectsRef.current.values()))
}
}, 100) // ~10 FPS reicht für sanftes Fade
return () => window.clearInterval(iv) return () => window.clearInterval(iv)
}, []) }, [])
// ---- 3) Overview laden (JSON ODER Valve-KV) // ---- Overview laden -------------------------------------------------------
const [overview, setOverview] = useState<Overview | null>(null) const [overview, setOverview] = useState<Overview | null>(null)
function overviewCandidates(mapKey: string) { const overviewCandidates = (mapKey: string) => {
const base = mapKey const base = mapKey
return [ return [
`/assets/resource/overviews/${base}.json`, `/assets/resource/overviews/${base}.json`,
@ -553,6 +486,7 @@ export default function LiveRadar({ matchId }: Props) {
`/assets/resource/overviews/${base}_s2.json`, `/assets/resource/overviews/${base}_s2.json`,
] ]
} }
const parseOverviewJson = (j: any): Overview | null => { const parseOverviewJson = (j: any): Overview | null => {
const posX = Number(j?.posX ?? j?.pos_x) const posX = Number(j?.posX ?? j?.pos_x)
const posY = Number(j?.posY ?? j?.pos_y) const posY = Number(j?.posY ?? j?.pos_y)
@ -561,6 +495,7 @@ export default function LiveRadar({ matchId }: Props) {
if (![posX, posY, scale].every(Number.isFinite)) return null if (![posX, posY, scale].every(Number.isFinite)) return null
return { posX, posY, scale, rotate } return { posX, posY, scale, rotate }
} }
const parseValveKvOverview = (txt: string): Overview | null => { const parseValveKvOverview = (txt: string): Overview | null => {
const clean = txt.replace(/\/\/.*$/gm, '') 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 pick = (k: string) => { const m = clean.match(new RegExp(`"\\s*${k}\\s*"\\s*"([^"]+)"`)); return m ? Number(m[1]) : NaN }
@ -569,6 +504,7 @@ export default function LiveRadar({ matchId }: Props) {
if (![posX, posY, scale].every(Number.isFinite)) return null if (![posX, posY, scale].every(Number.isFinite)) return null
return { posX, posY, scale, rotate } return { posX, posY, scale, rotate }
} }
useEffect(() => { useEffect(() => {
let cancel = false let cancel = false
;(async () => { ;(async () => {
@ -588,14 +524,7 @@ export default function LiveRadar({ matchId }: Props) {
return () => { cancel = true } return () => { cancel = true }
}, [activeMapKey]) }, [activeMapKey])
// ---- 4) Radarbild-Pfade // ---- 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(() => { const { folderKey, imageCandidates } = useMemo(() => {
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] } if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey
@ -615,10 +544,10 @@ export default function LiveRadar({ matchId }: Props) {
useEffect(() => { setSrcIdx(0) }, [folderKey]) useEffect(() => { setSrcIdx(0) }, [folderKey])
const currentSrc = imageCandidates[srcIdx] const currentSrc = imageCandidates[srcIdx]
// ---- 5) Bildgröße // ---- Bildgröße ------------------------------------------------------------
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null) const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null)
// ---- 6) Welt→Pixel + Einheiten→Pixel ------------------------------------- // ---- Welt→Pixel & Einheiten→Pixel ----------------------------------------
type Mapper = (xw: number, yw: number) => { x: number; y: number } type Mapper = (xw: number, yw: number) => { x: number; y: number }
const worldToPx: Mapper = useMemo(() => { const worldToPx: Mapper = useMemo(() => {
@ -631,8 +560,7 @@ export default function LiveRadar({ matchId }: Props) {
return { x: imgSize.w / 2 + xw * k, y: imgSize.h / 2 - yw * k } return { x: imgSize.w / 2 + xw * k, y: imgSize.h / 2 - yw * k }
} }
} }
const { posX, posY, scale } = overview const { posX, posY, scale, rotate = 0 } = overview
const rotDeg = overview.rotate ?? 0
const w = imgSize.w, h = imgSize.h const w = imgSize.w, h = imgSize.h
const cx = w / 2, cy = h / 2 const cx = w / 2, cy = h / 2
@ -646,10 +574,10 @@ export default function LiveRadar({ matchId }: Props) {
const candidates: Mapper[] = [] const candidates: Mapper[] = []
for (const base of bases) { for (const base of bases) {
for (const s of rotSigns) { for (const s of rotSigns) {
const theta = (rotDeg * s * Math.PI) / 180 const theta = (rotate * s * Math.PI) / 180
candidates.push((xw, yw) => { candidates.push((xw, yw) => {
const p = base(xw, yw) const p = base(xw, yw)
if (rotDeg === 0) return p if (rotate === 0) return p
const dx = p.x - cx, dy = p.y - cy const dx = p.x - cx, dy = p.y - cy
const xr = dx * Math.cos(theta) - dy * Math.sin(theta) const xr = dx * Math.cos(theta) - dy * Math.sin(theta)
const yr = dx * Math.sin(theta) + dy * Math.cos(theta) const yr = dx * Math.sin(theta) + dy * Math.cos(theta)
@ -686,7 +614,7 @@ export default function LiveRadar({ matchId }: Props) {
return (u: number) => u * k return (u: number) => u * k
}, [imgSize, overview]) }, [imgSize, overview])
// ---- Status-Badge // ---- 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' :
@ -706,32 +634,7 @@ export default function LiveRadar({ matchId }: Props) {
) )
} }
function splitSegments(points: NadePoint[], jumpWorld = 64): NadePoint[][] { // ---- Render ---------------------------------------------------------------
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">
@ -785,7 +688,7 @@ export default function LiveRadar({ matchId }: Props) {
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`} viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
> >
{/* SVG-Defs für Filter */} {/* SVG-Defs */}
<defs> <defs>
<filter id="smoke-blur" x="-50%" y="-50%" width="200%" height="200%"> <filter id="smoke-blur" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="8" /> <feGaussianBlur stdDeviation="8" />
@ -800,7 +703,7 @@ export default function LiveRadar({ matchId }: Props) {
</radialGradient> </radialGradient>
</defs> </defs>
{/* --- Effekte unter den Spielern (SVG-Icon-basiert) --- */} {/* Effekte */}
{effects.map(e => { {effects.map(e => {
const { x, y } = worldToPx(e.x, e.y) const { x, y } = worldToPx(e.x, e.y)
if (!Number.isFinite(x) || !Number.isFinite(y)) return null if (!Number.isFinite(x) || !Number.isFinite(y)) return null
@ -808,107 +711,116 @@ export default function LiveRadar({ matchId }: Props) {
const R_WORLD = e.type === 'smoke' ? 170 : 110 const R_WORLD = e.type === 'smoke' ? 170 : 110
const halfPx = Math.max(12, unitsToPx(R_WORLD)) const halfPx = Math.max(12, unitsToPx(R_WORLD))
const sBase = (halfPx * 2) / 640 const sBase = (halfPx * 2) / 640
const s = sBase * (e.type === 'smoke' ? SMOKE_ICON_SCALE : FIRE_ICON_SCALE) const s = sBase * (e.type === 'smoke' ? UI.effects.smokeIconScale : UI.effects.fireIconScale)
const baseT = `translate(${x},${y}) scale(${s}) translate(-320,-320)` const baseT = `translate(${x},${y}) scale(${s}) translate(-320,-320)`
let fadeAlpha = 1 let fadeAlpha = 1
if (e.type === 'smoke' && e.fadeUntil) { if (e.type === 'smoke' && e.fadeUntil) {
const remain = Math.max(0, e.fadeUntil - Date.now()) const remain = Math.max(0, e.fadeUntil - Date.now())
fadeAlpha = Math.min(1, remain / SMOKE_FADE_MS) fadeAlpha = Math.min(1, remain / UI.effects.smokeFadeMs)
} }
if (e.type === 'smoke') { if (e.type === 'smoke') {
return ( return (
<g filter="url(#smoke-blur)" opacity={SMOKE_GROUP_OPACITY * fadeAlpha} transform={baseT}> <g filter="url(#smoke-blur)" opacity={UI.effects.smokeOpacity * fadeAlpha} transform={baseT}>
<path d={SMOKE_PATH} fill="#949494" fillOpacity={SMOKE_FILL_OPACITY} /> <path d={SMOKE_PATH} fill="#949494" fillOpacity={UI.effects.smokeFillOpacity} />
</g> </g>
) )
} }
return ( return (
<g filter="url(#fire-blur)" opacity={FIRE_GROUP_OPACITY} transform={baseT}> <g filter="url(#fire-blur)" opacity={UI.effects.fireOpacity} transform={baseT}>
<path d={FIRE_PATH} fill="url(#fire-grad)" /> <path d={FIRE_PATH} fill="url(#fire-grad)" />
</g> </g>
) )
})} })}
{/* --- Spieler darüber --- */} {/* Spieler */}
{players {players
.filter(p => p.team === 'CT' || p.team === 'T') // <— nur CT/T .filter(p => p.team === 'CT' || p.team === 'T')
.map((p) => { .map((p) => {
const A = worldToPx(p.x, p.y) const A = worldToPx(p.x, p.y)
const base = Math.min(imgSize.w, imgSize.h) const base = Math.min(imgSize.w, imgSize.h)
const r = Math.max(4, base * 0.008) const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
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 dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel)
const stroke = UI.player.stroke
const strokeW = Math.max(1, r * UI.player.lineWidthRel)
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
const yawRad = Number.isFinite(p.yaw) ? (p.yaw * Math.PI) / 180 : 0
// Richtung als Welt-Schritt (respektiert Overview-Rotation)
const STEP_WORLD = 200 const STEP_WORLD = 200
const B = worldToPx( const B = worldToPx(
p.x + Math.cos(yawRad) * STEP_WORLD, p.x + Math.cos(yawRad) * STEP_WORLD,
p.y + Math.sin(yawRad) * STEP_WORLD p.y + Math.sin(yawRad) * STEP_WORLD
) )
let dxp = B.x - A.x, dyp = B.y - A.y let dxp = B.x - A.x, dyp = B.y - A.y
if (!Number.isFinite(dxp) || !Number.isFinite(dyp)) { dxp = STEP_WORLD; dyp = 0 }
const cur = Math.hypot(dxp, dyp) || 1 const cur = Math.hypot(dxp, dyp) || 1
dxp *= dirLenPx / cur dxp *= dirLenPx / cur
dyp *= dirLenPx / cur dyp *= dirLenPx / cur
return ( return (
<g key={p.id}> <g key={p.id}>
<line x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp} {/* Kreis zuerst */}
stroke={color} strokeWidth={strokeW} strokeLinecap="round" <circle
opacity={p.alive === false ? 0.5 : 1}/> cx={A.x} cy={A.y} r={r}
<circle cx={A.x} cy={A.y} r={r} fill={fillColor} stroke={stroke}
fill={color} stroke={stroke} strokeWidth={Math.max(1, r*0.3)}
strokeWidth={Math.max(1, r*0.3)} opacity={p.alive === false ? 0.6 : 1}
opacity={p.alive === false ? 0.6 : 1}/> />
{/* Linie darüber (sichtbar) */}
<line
x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp}
stroke={dirColor} strokeWidth={strokeW} strokeLinecap="round"
opacity={p.alive === false ? 0.5 : 1}
/>
</g> </g>
) )
})} })}
{/* --- Nade-Pfade (unter Effekten & Spielern) --- */} {/* Nade-Pfade */}
<g opacity={0.95}> <g opacity={0.95}>
{nadePaths.map(np => { {nadePaths.map(np => {
const col = nadeColor(np.kind) const col = nadeColor(np.kind)
const wBase = Math.min(imgSize.w, imgSize.h) const wBase = Math.min(imgSize.w, imgSize.h)
const strokeW = Math.max(2, wBase * 0.004) const strokeW = Math.max(2, wBase * 0.004)
const dotR = Math.max(1.5, wBase * 0.0025) 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)
let alpha = 1 const pts = np.points
if (np.endedMs) alpha = Math.max(0, 1 - (Date.now() - np.endedMs) / NADE_PATH_TTL) .map(p => worldToPx(p.x, p.y))
.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
if (pts.length === 0) return null
const pts = np.points const d = pts.length >= 2
.map(p => worldToPx(p.x, p.y)) ? `M ${pts[0].x} ${pts[0].y} ` + pts.slice(1).map(p => `L ${p.x} ${p.y}`).join(' ')
.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y)) : null
if (pts.length === 0) return null return (
<g key={np.id} opacity={alpha}>
const d = pts.length >= 2 {d && (
? `M ${pts[0].x} ${pts[0].y} ` + pts.slice(1).map(p => `L ${p.x} ${p.y}`).join(' ') <path
: null d={d}
fill="none"
return ( stroke={col}
<g key={np.id} opacity={alpha}> strokeWidth={strokeW}
{d && ( strokeLinecap="round"
<path strokeLinejoin="round"
d={d} />
fill="none" )}
stroke={col} {pts.map((p, i) => (
strokeWidth={strokeW} <circle key={`${np.id}:pt:${i}`} cx={p.x} cy={p.y} r={dotR} fill={col} opacity={0.9} />
strokeLinecap="round" ))}
strokeLinejoin="round" </g>
/> )
)} })}
{/* Punkte anzeigen, damit sofort etwas sichtbar ist */} </g>
{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>
)} )}
</> </>