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 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;
// ---------- Konfiguration (UI & Verhalten) ----------
const UI = {
player: {
minRadiusPx: 4,
radiusRel: 0.008, // Radius relativ zur kleineren Bildkante
dirLenRel: 0.70, // Anteil des Radius, Linie bleibt im Kreis
dirMinLenPx: 6,
lineWidthRel: 0.25, // Linienbreite relativ zum Radius
stroke: '#ffffff',
fillCT: '#3b82f6',
fillT: '#f59e0b',
// 'auto' = automatisch kontrastierend zum Kreis, sonst fixe Farbe wie '#fff'
dirColor: 'auto' as 'auto' | string,
},
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 {
if (t === 2 || t === 'T' || t === 't') return 'T';
if (t === 3 || t === 'CT' || t === 'ct') return 'CT';
return String(t ?? '');
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 }
@ -62,16 +103,15 @@ type Effect = {
z: number
startMs: number
ttlMs: number
// neu:
ending?: boolean
fadeUntil?: number
}
// --- Nade-Pfade -------------------------------------------------------------
// --- Nade-Pfade --------------------------------------------------------------
type NadePoint = { x: number; y: number; z?: number; t?: number; s?: number }
type NadePath = {
id: string
kind: string // 'smoke' | 'flash' | 'he' | 'molotov' | ...
kind: string
points: NadePoint[]
startedMs: number
endedMs?: number
@ -81,14 +121,24 @@ 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
if (k.includes('smoke')) return '#94a3b8'
if (k.includes('flash')) return '#fbbf24'
if (k.includes('molotov') || k.includes('incen') || k === 'fire') return '#f97316'
if (k.includes('he') || k.includes('frag')) return '#ef4444'
if (k.includes('decoy')) return '#22c55e'
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) {
// ---- MapVote (Backup)
const [loading, setLoading] = useState(true)
@ -116,29 +166,32 @@ export default function LiveRadar({ matchId }: Props) {
}
}, [])
// 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";
// ---- 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 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 ------------------------------------------------------------
// ---- Nade-Pfade & Effekte -------------------------------------------------
const nadePathsRef = useRef<Map<string, NadePath>>(new Map())
const [nadePaths, setNadePaths] = useState<NadePath[]>([])
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(() => {
let cancel = false
;(async () => {
@ -161,37 +214,7 @@ export default function LiveRadar({ matchId }: Props) {
return () => { cancel = true }
}, [matchId])
// ---- Spieler-Overlay (ge-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)
}
// ---- 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)
// ---- Aktive Map bestimmen -------------------------------------------------
const voteMapKey = useMemo(() => {
const chosen = (voteData?.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
return chosen[0]?.map ?? null
@ -202,7 +225,7 @@ export default function LiveRadar({ matchId }: Props) {
if (!activeMapKey && voteMapKey) setActiveMapKey(voteMapKey)
}, [voteMapKey, activeMapKey])
// ---- 2) WS verbinden: Map + Players + Effekte
// ---- WebSocket verbinden --------------------------------------------------
useEffect(() => {
if (typeof window === 'undefined') return
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 retry: number | null = null
// Hilfsfunktionen für Effekte
// Effekte hinzufügen/entfernen
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)
const ttlMs = type === 'smoke' ? 19000 : 7000
effectsRef.current.set(id, { id, type, x, y, z, startMs: Date.now(), ttlMs })
syncEffects()
}
@ -235,12 +258,10 @@ export default function LiveRadar({ matchId }: Props) {
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
e.fadeUntil = Date.now() + UI.effects.smokeFadeMs
effectsRef.current.set(key, e)
} else {
// fire weiterhin sofort entfernen
effectsRef.current.delete(key)
}
syncEffects()
@ -248,9 +269,8 @@ export default function LiveRadar({ matchId }: Props) {
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 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
@ -263,7 +283,7 @@ export default function LiveRadar({ matchId }: Props) {
if (type === 'smoke') {
const e = effectsRef.current.get(bestKey)!
e.ending = true
e.fadeUntil = Date.now() + SMOKE_FADE_MS
e.fadeUntil = Date.now() + UI.effects.smokeFadeMs
effectsRef.current.set(bestKey, e)
} else {
effectsRef.current.delete(bestKey)
@ -273,19 +293,14 @@ export default function LiveRadar({ matchId }: Props) {
}
}
const connect = () => {
if (!alive) return
setWsStatus('connecting')
ws = new WebSocket(url)
// Nade-Trace verarbeiten
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
// ---- helper: trace-objekt in NadePath mergen + Effekte erzeugen/entfernen
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 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)
@ -300,7 +315,6 @@ export default function LiveRadar({ matchId }: Props) {
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
@ -309,52 +323,51 @@ export default function LiveRadar({ matchId }: Props) {
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)
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()
}
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) }
}
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) => {
let msg: any = null
try { msg = JSON.parse(ev.data as string) } catch {}
const tickTs = Number(msg?.t ?? Date.now())
const handleEvent = (e: any) => {
if (!e) return
// 1) Map-Event
// Map wechseln
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
}
// 2) Effekte -> NICHT als Player behandeln
// Effekte
if (e.type === 'smoke' || e.type === 'fire') {
const t = e.type as EffectType
if (e.state === 'start') addEffect(t, e)
@ -362,64 +375,12 @@ export default function LiveRadar({ matchId }: Props) {
return
}
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
}
// Grenade-Traces
if (e.type === 'nade' || e.kind || e.nade || e.weapon) {
upsertNadeTrace(e)
}
// 3) Player-ähnliche Events
// Spieler
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 ?? '')
@ -432,8 +393,20 @@ export default function LiveRadar({ matchId }: Props) {
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 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 = {
id,
@ -454,39 +427,10 @@ export default function LiveRadar({ matchId }: Props) {
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.onerror = () => setWsStatus('error')
ws.onclose = () => {
setWsStatus('closed')
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(() => {
const iv = window.setInterval(() => {
const now = Date.now()
@ -510,40 +454,29 @@ export default function LiveRadar({ matchId }: Props) {
for (const [k, e] of effectsRef.current) {
haveAny = true
// wenn beendet & Fade vorbei -> löschen
if (e.ending && e.fadeUntil && now >= e.fadeUntil) {
effectsRef.current.delete(k)
changed = true
continue
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
effectsRef.current.delete(k); changed = true
}
}
// --- Nade-Pfade nach TTL entfernen ---
for (const [k, np] of nadePathsRef.current) {
if (np.endedMs && Date.now() - np.endedMs > NADE_PATH_TTL) {
nadePathsRef.current.delete(k)
changed = true
if (np.endedMs && now - np.endedMs > NADE_PATH_TTL) {
nadePathsRef.current.delete(k); changed = true
}
}
if (changed) setNadePaths(Array.from(nadePathsRef.current.values()))
// Repaint erzwingen, damit Fade-Opacity sichtbar animiert
if (changed || haveAny) {
setEffects(Array.from(effectsRef.current.values()))
}
}, 100) // ~10 FPS reicht für sanftes Fade
if (changed || haveAny) setEffects(Array.from(effectsRef.current.values()))
}, 100)
return () => window.clearInterval(iv)
}, [])
// ---- 3) Overview laden (JSON ODER Valve-KV)
// ---- Overview laden -------------------------------------------------------
const [overview, setOverview] = useState<Overview | null>(null)
function overviewCandidates(mapKey: string) {
const overviewCandidates = (mapKey: string) => {
const base = mapKey
return [
`/assets/resource/overviews/${base}.json`,
@ -553,6 +486,7 @@ export default function LiveRadar({ matchId }: Props) {
`/assets/resource/overviews/${base}_s2.json`,
]
}
const parseOverviewJson = (j: any): Overview | null => {
const posX = Number(j?.posX ?? j?.pos_x)
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
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 }
@ -569,6 +504,7 @@ export default function LiveRadar({ matchId }: Props) {
if (![posX, posY, scale].every(Number.isFinite)) return null
return { posX, posY, scale, rotate }
}
useEffect(() => {
let cancel = false
;(async () => {
@ -588,14 +524,7 @@ export default function LiveRadar({ matchId }: Props) {
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])
// ---- Radarbild-Pfade ------------------------------------------------------
const { folderKey, imageCandidates } = useMemo(() => {
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey
@ -615,10 +544,10 @@ export default function LiveRadar({ matchId }: Props) {
useEffect(() => { setSrcIdx(0) }, [folderKey])
const currentSrc = imageCandidates[srcIdx]
// ---- 5) Bildgröße
// ---- Bildgröße ------------------------------------------------------------
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 }
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 }
}
}
const { posX, posY, scale } = overview
const rotDeg = overview.rotate ?? 0
const { posX, posY, scale, rotate = 0 } = overview
const w = imgSize.w, h = imgSize.h
const cx = w / 2, cy = h / 2
@ -646,10 +574,10 @@ export default function LiveRadar({ matchId }: Props) {
const candidates: Mapper[] = []
for (const base of bases) {
for (const s of rotSigns) {
const theta = (rotDeg * s * Math.PI) / 180
const theta = (rotate * s * Math.PI) / 180
candidates.push((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 xr = dx * Math.cos(theta) - dy * Math.sin(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
}, [imgSize, overview])
// ---- Status-Badge
// ---- Status-Badge ---------------------------------------------------------
const WsDot = ({ status }: { status: typeof wsStatus }) => {
const color =
status === 'open' ? 'bg-green-500' :
@ -706,32 +634,7 @@ 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
// ---- Render ---------------------------------------------------------------
return (
<div className="p-4">
<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}`}
preserveAspectRatio="xMidYMid meet"
>
{/* SVG-Defs für Filter */}
{/* SVG-Defs */}
<defs>
<filter id="smoke-blur" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="8" />
@ -800,7 +703,7 @@ export default function LiveRadar({ matchId }: Props) {
</radialGradient>
</defs>
{/* --- Effekte unter den Spielern (SVG-Icon-basiert) --- */}
{/* Effekte */}
{effects.map(e => {
const { x, y } = worldToPx(e.x, e.y)
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 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 s = sBase * (e.type === 'smoke' ? UI.effects.smokeIconScale : UI.effects.fireIconScale)
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)
fadeAlpha = Math.min(1, remain / UI.effects.smokeFadeMs)
}
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 filter="url(#smoke-blur)" opacity={UI.effects.smokeOpacity * fadeAlpha} transform={baseT}>
<path d={SMOKE_PATH} fill="#949494" fillOpacity={UI.effects.smokeFillOpacity} />
</g>
)
}
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)" />
</g>
)
})}
{/* --- Spieler darüber --- */}
{/* Spieler */}
{players
.filter(p => p.team === 'CT' || p.team === 'T') // <— nur CT/T
.filter(p => p.team === 'CT' || p.team === '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 r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
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 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
if (!Number.isFinite(dxp) || !Number.isFinite(dyp)) { dxp = STEP_WORLD; dyp = 0 }
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}/>
{/* Kreis zuerst */}
<circle
cx={A.x} cy={A.y} r={r}
fill={fillColor} stroke={stroke}
strokeWidth={Math.max(1, r*0.3)}
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>
)
})}
{/* --- 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)
{/* Nade-Pfade */}
<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)
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 pts = np.points
.map(p => worldToPx(p.x, p.y))
.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
const d = pts.length >= 2
? `M ${pts[0].x} ${pts[0].y} ` + pts.slice(1).map(p => `L ${p.x} ${p.y}`).join(' ')
: null
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>
return (
<g key={np.id} opacity={alpha}>
{d && (
<path
d={d}
fill="none"
stroke={col}
strokeWidth={strokeW}
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{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>
)}
</>