From 02b2a8efc8fa768476db06cf48ec3cc3ac45351a Mon Sep 17 00:00:00 2001 From: Linrador Date: Tue, 19 Aug 2025 22:41:45 +0200 Subject: [PATCH] updated player angle --- src/app/components/LiveRadar.tsx | 582 +++++++++++++------------------ 1 file changed, 247 insertions(+), 335 deletions(-) diff --git a/src/app/components/LiveRadar.tsx b/src/app/components/LiveRadar.tsx index c4dc342..31f5716 100644 --- a/src/app/components/LiveRadar.tsx +++ b/src/app/components/LiveRadar.tsx @@ -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>(new Map()) const [nadePaths, setNadePaths] = useState([]) const syncNadePaths = () => setNadePaths(Array.from(nadePathsRef.current.values())) + const effectsRef = useRef>(new Map()) + const [effects, setEffects] = useState([]) + const syncEffects = () => setEffects(Array.from(effectsRef.current.values())) + // ---- Spieler (throttled) -------------------------------------------------- + const playersRef = useRef>(new Map()) + const [players, setPlayers] = useState([]) + const flushTimer = useRef(null) + const scheduleFlush = () => { + if (flushTimer.current != null) return + flushTimer.current = window.setTimeout(() => { + flushTimer.current = null + setPlayers(Array.from(playersRef.current.values())) + }, 66) + } - // ---- 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>(new Map()) - const [players, setPlayers] = useState([]) - const flushTimer = useRef(null) - const scheduleFlush = () => { - if (flushTimer.current != null) return - flushTimer.current = window.setTimeout(() => { - flushTimer.current = null - setPlayers(Array.from(playersRef.current.values())) - }, 66) - } - - // ---- Effekte-State (seltenere Updates, kein Throttle nötig – aber Map + Sync) - const effectsRef = useRef>(new Map()) - const [effects, setEffects] = useState([]) - 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, @@ -453,40 +426,11 @@ export default function LiveRadar({ matchId }: Props) { } 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.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(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 (
@@ -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 */} @@ -800,7 +703,7 @@ export default function LiveRadar({ matchId }: Props) { - {/* --- 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 ( - - + + ) } - return ( - + ) })} - {/* --- 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 ( - - + {/* Kreis zuerst */} + + {/* Linie darüber (sichtbar) */} + ) })} - {/* --- Nade-Pfade (unter Effekten & Spielern) --- */} - - {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 */} + + {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 ( - - {d && ( - - )} - {/* Punkte anzeigen, damit sofort etwas sichtbar ist */} - {pts.map((p, i) => ( - - ))} - - ) - })} - + return ( + + {d && ( + + )} + {pts.map((p, i) => ( + + ))} + + ) + })} + )}