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