861 lines
33 KiB
TypeScript
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>
|
|
)
|
|
}
|