2025-09-09 13:12:57 +02:00

861 lines
33 KiB
TypeScript

'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import MetaSocket from './MetaSocket'
import PositionsSocket from './PositionsSocket'
import TeamSidebar from './TeamSidebar'
/* ───────── UI config ───────── */
const UI = {
player: {
minRadiusPx: 4,
radiusRel: 0.008,
dirLenRel: 0.70,
dirMinLenPx: 6,
lineWidthRel: 0.25,
stroke: '#ffffff',
bombStroke: '#ef4444',
fillCT: '#3b82f6',
fillT: '#f59e0b',
dirColor: 'auto' as 'auto' | string,
},
nade: {
stroke: '#111111',
smokeFill: 'rgba(160,160,160,0.35)',
fireFill: 'rgba(255,128,0,0.35)',
heFill: 'rgba(90,160,90,0.9)',
flashFill: 'rgba(255,255,255,0.95)',
decoyFill: 'rgba(140,140,255,0.25)',
teamStrokeCT: '#3b82f6',
teamStrokeT: '#f59e0b',
minRadiusPx: 6,
},
death: {
stroke: '#9ca3af',
lineWidthPx: 2,
sizePx: 10,
},
trail: {
maxPoints: 60,
fadeMs: 1500,
stroke: 'rgba(60,60,60,0.7)',
widthPx: 2,
}
}
/* ───────── helpers ───────── */
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'
}
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 ?? '')
}
function detectHasBomb(src: any): boolean {
const flags = [
'hasBomb','has_bomb','bomb','c4','hasC4','carryingBomb','bombCarrier','isBombCarrier'
]
for (const k of flags) {
if (typeof src?.[k] === 'boolean') return !!src[k]
if (typeof src?.[k] === 'string') {
const s = String(src[k]).toLowerCase()
if (s === 'true' || s === '1' || s === 'c4' || s.includes('bomb')) return true
}
}
const arrays = [src?.weapons, src?.inventory, src?.items]
for (const arr of arrays) {
if (!arr) continue
if (Array.isArray(arr)) {
if (arr.some((w:any)=>
typeof w === 'string'
? w.toLowerCase().includes('c4') || w.toLowerCase().includes('bomb')
: (w?.name||w?.type||w?.weapon||'').toLowerCase().includes('c4') ||
(w?.name||w?.type||w?.weapon||'').toLowerCase().includes('bomb')
)) return true
} else if (typeof arr === 'object') {
const vals = Object.values(arr)
if (vals.some((w:any)=>
typeof w === 'string'
? w.toLowerCase().includes('c4') || w.toLowerCase().includes('bomb')
: (w?.name||w?.type||w?.weapon||'').toLowerCase().includes('c4') ||
(w?.name||w?.type||w?.weapon||'').toLowerCase().includes('bomb')
)) return true
}
}
return false
}
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
const h = (host ?? '').trim() || '127.0.0.1'
const p = (port ?? '').trim() || '8081'
const pa = (path ?? '').trim() || '/telemetry'
const sch = (scheme ?? '').toLowerCase()
const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
const useWss = sch === 'wss' || (sch !== 'ws' && (p === '443' || pageHttps))
const proto = useWss ? 'wss' : 'ws'
const portPart = (p === '80' || p === '443') ? '' : `:${p}`
return `${proto}://${h}${portPart}${pa}`
}
const metaUrl = makeWsUrl(
process.env.NEXT_PUBLIC_CS2_META_WS_HOST,
process.env.NEXT_PUBLIC_CS2_META_WS_PORT,
process.env.NEXT_PUBLIC_CS2_META_WS_PATH,
process.env.NEXT_PUBLIC_CS2_META_WS_SCHEME
)
const posUrl = makeWsUrl(
process.env.NEXT_PUBLIC_CS2_POS_WS_HOST,
process.env.NEXT_PUBLIC_CS2_POS_WS_PORT,
process.env.NEXT_PUBLIC_CS2_POS_WS_PATH,
process.env.NEXT_PUBLIC_CS2_POS_WS_SCHEME
)
const RAD2DEG = 180 / Math.PI
const normalizeDeg = (d: number) => (d % 360 + 360) % 360
const parseVec3String = (str?: string) => {
if (!str || typeof str !== 'string') return { x: 0, y: 0, z: 0 }
const [x, y, z] = str.split(',').map(s => Number(s.trim()))
return { x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0, z: Number.isFinite(z) ? z : 0 }
}
const asNum = (n: any, def=0) => { const v = Number(n); return Number.isFinite(v) ? v : def }
/* ───────── types ───────── */
type PlayerState = {
id: string
name?: string | null
team?: 'T' | 'CT' | string
x: number
y: number
z: number
yaw?: number | null
alive?: boolean
hasBomb?: boolean
hp?: number | null
armor?: number | null
helmet?: boolean | null
defuse?: boolean | null
}
type Grenade = {
id: string
kind: 'smoke' | 'molotov' | 'he' | 'flash' | 'decoy' | 'unknown'
x: number
y: number
z: number
radius?: number | null
expiresAt?: number | null
team?: 'T' | 'CT' | string | null
}
type DeathMarker = { id: string; x: number; y: number; t: number }
type Trail = { id: string; kind: Grenade['kind']; pts: {x:number,y:number}[]; lastSeen: number }
type Overview = { posX: number; posY: number; scale: number; rotate?: number }
type Mapper = (xw: number, yw: number) => { x: number; y: number }
type WsStatus = 'idle'|'connecting'|'open'|'closed'|'error'
/* ───────── Komponente ───────── */
export default function LiveRadar() {
// WS-Status
const [metaWsStatus, setMetaWsStatus] = useState<WsStatus>('idle')
const [posWsStatus, setPosWsStatus] = useState<WsStatus>('idle')
// Map
const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
// Spieler
const playersRef = useRef<Map<string, PlayerState>>(new Map())
const [players, setPlayers] = useState<PlayerState[]>([])
// Grenaden + Trails
const grenadesRef = useRef<Map<string, Grenade>>(new Map())
const [grenades, setGrenades] = useState<Grenade[]>([])
const trailsRef = useRef<Map<string, Trail>>(new Map())
const [trails, setTrails] = useState<Trail[]>([])
// Death-Marker
const deathMarkersRef = useRef<DeathMarker[]>([])
const [deathMarkers, setDeathMarkers] = useState<DeathMarker[]>([])
// Flush
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()))
setGrenades(Array.from(grenadesRef.current.values()))
setTrails(Array.from(trailsRef.current.values()))
setDeathMarkers([...deathMarkersRef.current])
}, 66)
}
useEffect(() => {
return () => {
if (flushTimer.current != null) {
window.clearTimeout(flushTimer.current)
flushTimer.current = null
}
}
}, [])
// Runden-/Map-Reset
const clearRoundArtifacts = () => {
deathMarkersRef.current = []
trailsRef.current.clear()
grenadesRef.current.clear()
scheduleFlush()
}
useEffect(() => {
if (activeMapKey) clearRoundArtifacts()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeMapKey])
/* ───────── Meta-Callbacks ───────── */
const handleMetaMap = (key: string) => setActiveMapKey(key.toLowerCase())
const handleMetaPlayersSnapshot = (list: Array<{ steamId: string|number; name?: string; team?: any }>) => {
for (const p of list) {
const id = String(p.steamId ?? '')
if (!id) continue
const old = playersRef.current.get(id)
playersRef.current.set(id, {
id,
name: p.name ?? old?.name ?? null,
team: mapTeam(p.team ?? old?.team),
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null,
alive: old?.alive,
hasBomb: old?.hasBomb ?? false,
})
}
scheduleFlush()
}
const handleMetaPlayerJoin = (p: any) => {
const id = String(p?.steamId ?? p?.id ?? p?.name ?? '')
if (!id) return
const old = playersRef.current.get(id)
playersRef.current.set(id, {
id,
name: p?.name ?? old?.name ?? null,
team: mapTeam(p?.team ?? old?.team),
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null,
alive: true,
hasBomb: old?.hasBomb ?? false,
})
scheduleFlush()
}
const handleMetaPlayerLeave = (steamId: string | number) => {
const id = String(steamId)
const old = playersRef.current.get(id)
if (old) {
playersRef.current.set(id, { ...old, alive: false })
scheduleFlush()
}
}
/* ───────── Positions-Callbacks ───────── */
const addDeathMarker = (x:number, y:number, idHint?: string) => {
deathMarkersRef.current.push({ id: idHint ?? `d#${Date.now()}`, x, y, t: Date.now() })
}
const upsertPlayer = (e: any) => {
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 yaw = Number(
e.yaw ??
e.viewAngle?.yaw ??
e.view?.yaw ??
e.aim?.yaw ??
e.ang?.y ??
e.angles?.y ??
e.rotation?.yaw
)
const old = playersRef.current.get(id)
const nextAlive = (e.alive !== undefined) ? !!e.alive : old?.alive
const hp = Number(
e.hp ?? e.health ?? e.state?.health
)
const armor = Number(
e.armor ?? e.state?.armor
)
const helmet = Boolean(
e.helmet ?? e.hasHelmet ?? e.state?.helmet
)
const defuse = Boolean(
e.defuse ?? e.hasDefuse ?? e.hasDefuser ?? e.state?.defusekit
)
const hasBomb = detectHasBomb(e) || old?.hasBomb
if (old?.alive !== false && nextAlive === false) addDeathMarker(x, y, id)
playersRef.current.set(id, {
id,
name: e.name ?? old?.name ?? null,
team: mapTeam(e.team ?? old?.team),
x, y, z,
yaw: Number.isFinite(yaw) ? yaw : old?.yaw ?? null,
alive: nextAlive,
hasBomb: !!hasBomb,
hp: Number.isFinite(hp) ? hp : old?.hp ?? null,
armor: Number.isFinite(armor) ? armor : old?.armor ?? null,
helmet: helmet ?? old?.helmet ?? null,
defuse: defuse ?? old?.defuse ?? null,
})
}
const handlePlayersAll = (msg: any) => {
const ap = msg?.allplayers
if (!ap || typeof ap !== 'object') return
let total = 0, aliveCount = 0
for (const key of Object.keys(ap)) {
const p = ap[key]
const pos = parseVec3String(p.position)
const fwd = parseVec3String(p.forward)
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
const id = String(key)
const old = playersRef.current.get(id)
const isAlive = p.state?.health > 0 || p.state?.health == null
const hp = Number(p.state?.health)
const armor = Number(p.state?.armor)
const helmet = !!p.state?.helmet
const defuse = !!p.state?.defusekit
const hasBomb = detectHasBomb(p) || old?.hasBomb
if ((old?.alive ?? true) && !isAlive) addDeathMarker(pos.x, pos.y, id)
playersRef.current.set(id, {
id,
name: p.name ?? old?.name ?? null,
team: mapTeam(p.team ?? old?.team),
x: pos.x, y: pos.y, z: pos.z,
yaw,
alive: isAlive,
hasBomb: !!hasBomb,
hp: Number.isFinite(hp) ? hp : old?.hp ?? null,
armor: Number.isFinite(armor) ? armor : old?.armor ?? null,
helmet: helmet ?? old?.helmet ?? null,
defuse: defuse ?? old?.defuse ?? null,
})
total++
if (isAlive) aliveCount++
}
if (total > 0 && aliveCount === total && (deathMarkersRef.current.length > 0 || trailsRef.current.size > 0 || grenadesRef.current.size > 0)) {
clearRoundArtifacts()
}
scheduleFlush()
}
const normalizeGrenades = (raw: any): Grenade[] => {
if (!raw) return []
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
const s = mapTeam(t)
return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? t : null)
}
if (Array.isArray(raw)) {
return raw.map((g: any, i: number) => {
const pos = g.pos ?? g.position ?? g.location ?? {}
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
typeof pos === 'string' ? parseVec3String(pos) : pos
return {
id: String(g.id ?? `${g.type ?? 'nade'}#${i}`),
kind: (String(g.type ?? g.kind ?? 'unknown').toLowerCase() as Grenade['kind']),
x: asNum(g.x ?? xyz?.x), y: asNum(g.y ?? xyz?.y), z: asNum(g.z ?? xyz?.z),
radius: Number.isFinite(Number(g.radius)) ? Number(g.radius) : null,
expiresAt: Number.isFinite(Number(g.expiresAt)) ? Number(g.expiresAt) : null,
team: pickTeam(g.team ?? g.owner_team ?? g.side ?? null),
}
})
}
const buckets: Record<string, string[]> = {
smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'],
molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'],
he: ['he', 'hegrenade', 'hegrenades', 'explosive'],
flash: ['flash', 'flashbang', 'flashbangs'],
decoy: ['decoy', 'decoys'],
}
const out: Grenade[] = []
const push = (kind: Grenade['kind'], list: any) => {
if (!list) return
const arr = Array.isArray(list) ? list : Object.values(list)
let i = 0
for (const g of arr) {
const pos = g?.pos ?? g?.position ?? g?.location
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
typeof pos === 'string' ? parseVec3String(pos) :
(pos || { x: g?.x, y: g?.y, z: g?.z })
const id = String(
g?.id ?? g?.entityid ?? g?.entindex ??
`${kind}#${asNum(xyz?.x)}:${asNum(xyz?.y)}:${asNum(xyz?.z)}:${i++}`
)
out.push({
id, kind,
x: asNum(xyz?.x), y: asNum(xyz?.y), z: asNum(xyz?.z),
radius: Number.isFinite(Number(g?.radius)) ? Number(g?.radius) : null,
expiresAt: Number.isFinite(Number(g?.expiresAt)) ? Number(g?.expiresAt) : null,
team: pickTeam(g?.team ?? g?.owner_team ?? g?.side ?? null),
})
}
}
for (const [kind, keys] of Object.entries(buckets)) {
for (const k of keys) if ((raw as any)[k]) push(kind as Grenade['kind'], (raw as any)[k])
}
if (out.length === 0 && typeof raw === 'object') {
for (const [k, v] of Object.entries(raw)) {
const kk = k.toLowerCase()
const kind =
kk.includes('smoke') ? 'smoke' :
kk.includes('flash') ? 'flash' :
kk.includes('molotov') || kk.includes('inferno') || kk.includes('fire') ? 'molotov' :
kk.includes('decoy') ? 'decoy' :
kk.includes('he') ? 'he' : 'unknown'
push(kind as Grenade['kind'], v)
}
}
return out
}
const handleGrenades = (g: any) => {
const list = normalizeGrenades(g)
// Trails updaten
const seen = new Set<string>()
const now = Date.now()
for (const it of list) {
seen.add(it.id)
const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 }
const last = prev.pts[prev.pts.length - 1]
if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) {
prev.pts.push({ x: it.x, y: it.y })
if (prev.pts.length > UI.trail.maxPoints) prev.pts = prev.pts.slice(-UI.trail.maxPoints)
}
prev.kind = it.kind
prev.lastSeen = now
trailsRef.current.set(it.id, prev)
}
// Trails ausdünnen
for (const [id, tr] of trailsRef.current) {
if (!seen.has(id) && now - tr.lastSeen > UI.trail.fadeMs) {
trailsRef.current.delete(id)
}
}
// aktuelle Nades übernehmen
const next = new Map<string, Grenade>()
for (const it of list) next.set(it.id, it)
grenadesRef.current = next
scheduleFlush()
}
// erster Flush
useEffect(() => {
if (!playersRef.current && !grenadesRef.current) return
scheduleFlush()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/* ───────── Overview + Radarbild ───────── */
const [overview, setOverview] = useState<Overview | null>(null)
const 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`,
]
}
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])
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 = imageCandidates[srcIdx]
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null)
/* ───────── Welt → Pixel ───────── */
const worldToPx: Mapper = useMemo(() => {
if (!imgSize || !overview) {
return (xw, yw) => {
if (!imgSize) return { x: 0, y: 0 }
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, rotate = 0 } = overview
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 = (rotate * s * Math.PI) / 180
candidates.push((xw, yw) => {
const p = base(xw, yw)
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)
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
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, label }: { status: WsStatus, label: string }) => {
const color =
status === 'open' ? 'bg-green-500' :
status === 'connecting' ? 'bg-amber-500' :
status === 'error' ? 'bg-red-500' :
'bg-neutral-400'
const txt =
status === 'open' ? 'verbunden' :
status === 'connecting' ? 'verbinde…' :
status === 'error' ? 'Fehler' :
status === 'closed' ? 'getrennt' : '—'
return (
<span className="inline-flex items-center gap-1 text-xs opacity-80">
<span className="font-medium">{label}</span>
<span className={`inline-block w-2.5 h-2.5 rounded-full ${color}`} />
{txt}
</span>
)
}
/* ───────── Render (Fix A: reines Flex-Layout) ───────── */
return (
<div className="h-full min-h-0 w-full flex flex-col overflow-hidden">
{/* Header */}
<div className="mb-4 shrink-0 flex items-center justify-between">
<h2 className="text-xl font-semibold">Live Radar</h2>
<div className="flex items-center gap-4">
<div className="text-sm opacity-80">
{activeMapKey ? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase() : '—'}
</div>
<WsDot status={metaWsStatus} label="Meta" />
<WsDot status={posWsStatus} label="Pos" />
</div>
</div>
{/* Unsichtbare WS-Clients */}
<MetaSocket
url={metaUrl}
onStatus={setMetaWsStatus}
onMap={(k)=> setActiveMapKey(k.toLowerCase())}
onPlayersSnapshot={handleMetaPlayersSnapshot}
onPlayerJoin={handleMetaPlayerJoin}
onPlayerLeave={handleMetaPlayerLeave}
/>
<PositionsSocket
url={posUrl}
onStatus={setPosWsStatus}
onMap={(k)=> setActiveMapKey(String(k).toLowerCase())}
onPlayerUpdate={(p)=> { upsertPlayer(p); scheduleFlush() }}
onPlayersAll={(m)=> { handlePlayersAll(m); scheduleFlush() }}
onGrenades={(g)=> { handleGrenades(g); scheduleFlush() }}
/>
{/* Inhalt: 3-Spalten-Layout (T | Radar | CT) */}
<div className="flex-1 min-h-0">
{!activeMapKey ? (
<div className="h-full grid place-items-center">
<div className="px-4 py-3 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
Keine Map erkannt.
</div>
</div>
) : (
<div className="h-full min-h-0 grid grid-cols-[minmax(180px,240px)_1fr_minmax(180px,240px)] gap-4">
{/* Left: T */}
<TeamSidebar
team="T"
players={players
.filter(p => p.team === 'T')
.map(p => ({
id: p.id, name: p.name, hp: p.hp, armor: p.armor, helmet: p.helmet,
defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive
}))
}
/>
{/* Center: Radar */}
<div className="relative min-h-0 rounded-lg overflow-hidden border border-neutral-700 bg-neutral-800">
{currentSrc ? (
<div className="absolute inset-0">
{/* Bild füllt Container (Letterboxing via object-contain) */}
<img
key={currentSrc}
src={currentSrc}
alt={activeMapKey ?? 'map'}
className="absolute inset-0 h-full w-full object-contain object-center"
onLoad={(e) => {
const img = e.currentTarget
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
}}
onError={() => {
if (srcIdx < imageCandidates.length - 1) setSrcIdx(i => i + 1)
}}
/>
{/* Overlay skaliert deckungsgleich zum Bild */}
{imgSize && (
<svg
className="absolute inset-0 h-full w-full object-contain pointer-events-none"
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
preserveAspectRatio="xMidYMid meet"
>
{/* Trails */}
{trails.map(tr => {
const pts = tr.pts.map(p => {
const q = worldToPx(p.x, p.y)
return `${q.x},${q.y}`
}).join(' ')
if (!pts) return null
return (
<polyline
key={`trail-${tr.id}`}
points={pts}
fill="none"
stroke={UI.trail.stroke}
strokeWidth={UI.trail.widthPx}
strokeLinecap="round"
strokeLinejoin="round"
/>
)
})}
{/* Grenades */}
{grenades.map((g) => {
const P = worldToPx(g.x, g.y)
const defR =
g.kind === 'smoke' ? 150 :
g.kind === 'molotov'? 120 :
g.kind === 'he' ? 40 :
g.kind === 'flash' ? 36 :
g.kind === 'decoy' ? 80 : 60
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? defR))
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
: g.team === 'T' ? UI.nade.teamStrokeT
: UI.nade.stroke
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
if (g.kind === 'smoke') {
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
}
if (g.kind === 'molotov') {
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.fireFill} stroke={stroke} strokeWidth={sw} />
}
if (g.kind === 'decoy') {
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.decoyFill} stroke={stroke} strokeWidth={sw} strokeDasharray="6,4" />
}
if (g.kind === 'flash') {
return (
<g key={g.id}>
<circle cx={P.x} cy={P.y} r={rPx*0.6} fill="none" stroke={stroke} strokeWidth={sw} />
<circle cx={P.x} cy={P.y} r={Math.max(2, rPx*0.25)} fill={UI.nade.flashFill} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
<line x1={P.x-rPx*0.9} y1={P.y} x2={P.x+rPx*0.9} y2={P.y} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
<line x1={P.x} y1={P.y-rPx*0.9} x2={P.x} y2={P.y+rPx*0.9} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
</g>
)
}
return <circle key={g.id} cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill={g.kind === 'he' ? UI.nade.heFill : '#999'} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
})}
{/* Spieler */}
{players
.filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false)
.map((p) => {
const A = worldToPx(p.x, p.y)
const base = Math.min(imgSize.w, imgSize.h)
const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
const dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel)
const stroke = p.hasBomb ? UI.player.bombStroke : 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
let dxp = 0, dyp = 0
if (Number.isFinite(p.yaw as number)) {
const yawRad = (Number(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
)
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}>
<circle
cx={A.x} cy={A.y} r={r}
fill={fillColor} stroke={stroke}
strokeWidth={Math.max(1, r*0.3)}
/>
{Number.isFinite(p.yaw as number) && (
<line
x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp}
stroke={dirColor} strokeWidth={strokeW} strokeLinecap="round"
/>
)}
</g>
)
})}
{/* Death-Marker */}
{deathMarkers.map(dm => {
const P = worldToPx(dm.x, dm.y)
const s = UI.death.sizePx
return (
<g key={`death-${dm.t}-${dm.x}-${dm.y}`}>
<line x1={P.x - s} y1={P.y - s} x2={P.x + s} y2={P.y + s}
stroke={UI.death.stroke} strokeWidth={UI.death.lineWidthPx} strokeLinecap="round" />
<line x1={P.x - s} y1={P.y + s} x2={P.x + s} y2={P.y - s}
stroke={UI.death.stroke} strokeWidth={UI.death.lineWidthPx} strokeLinecap="round" />
</g>
)
})}
</svg>
)}
</div>
) : (
<div className="absolute inset-0 grid place-items-center p-6 text-center">
Keine Radar-Grafik gefunden.
</div>
)}
</div>
{/* Right: CT */}
<TeamSidebar
team="CT"
align="right"
players={players
.filter(p => p.team === 'CT')
.map(p => ({
id: p.id, name: p.name, hp: p.hp, armor: p.armor, helmet: p.helmet,
defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive
}))
}
/>
</div>
)}
</div>
</div>
)
}