From 3c68c3ad2cf6a2023a493e040a4032580457b169 Mon Sep 17 00:00:00 2001 From: Linrador Date: Wed, 10 Sep 2025 23:33:10 +0200 Subject: [PATCH] update --- src/app/components/radar/LiveRadar.tsx | 641 ++++++++++++++--------- src/app/components/radar/TeamSidebar.tsx | 104 +++- src/app/lib/useAvatarDirectoryStore.ts | 99 ++++ 3 files changed, 573 insertions(+), 271 deletions(-) create mode 100644 src/app/lib/useAvatarDirectoryStore.ts diff --git a/src/app/components/radar/LiveRadar.tsx b/src/app/components/radar/LiveRadar.tsx index a2473ac..fd6f5b1 100644 --- a/src/app/components/radar/LiveRadar.tsx +++ b/src/app/components/radar/LiveRadar.tsx @@ -1,9 +1,12 @@ +// src/app/components/radar/LiveRadar.tsx 'use client' import { useEffect, useMemo, useRef, useState } from 'react' import MetaSocket from './MetaSocket' import PositionsSocket from './PositionsSocket' import TeamSidebar from './TeamSidebar' +import Switch from '../Switch' +import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore' /* ───────── UI config ───────── */ const UI = { @@ -18,6 +21,10 @@ const UI = { fillCT: '#3b82f6', fillT: '#f59e0b', dirColor: 'auto' as 'auto' | string, + iconScale: 1.2, + avatarScale: 2, // Avatare deutlich größer als Icons + avatarRingWidthRel: 0.28, // Team-Ring um Avatare + avatarDirArcDeg: 18, // <- NEU: Winkelspanne des Bogens in Grad }, nade: { stroke: '#111111', @@ -33,7 +40,7 @@ const UI = { death: { stroke: '#9ca3af', lineWidthPx: 2, - sizePx: 20, + sizePx: 24, }, trail: { maxPoints: 60, @@ -44,14 +51,12 @@ const UI = { } /* ───────── helpers ───────── */ - const steamIdOf = (src:any): string | null => { const raw = src?.steamId ?? src?.steam_id ?? src?.steamid const s = raw != null ? String(raw) : '' return s && s !== '0' ? s : null } - function contrastStroke(hex: string) { const h = hex.replace('#','') const r = parseInt(h.slice(0,2),16)/255 @@ -69,9 +74,7 @@ function mapTeam(t: any): 'T' | 'CT' | string { } function detectHasBomb(src: any): boolean { - const flags = [ - 'hasBomb','has_bomb','bomb','c4','hasC4','carryingBomb','bombCarrier','isBombCarrier' - ] + 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') { @@ -120,7 +123,6 @@ const metaUrl = makeWsUrl( 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, @@ -128,6 +130,8 @@ const posUrl = makeWsUrl( process.env.NEXT_PUBLIC_CS2_POS_WS_SCHEME ) +const DEFAULT_AVATAR = '/assets/img/avatars/default_steam_avatar.jpg' + const RAD2DEG = 180 / Math.PI const normalizeDeg = (d: number) => (d % 360 + 360) % 360 const parseVec3String = (str?: string) => { @@ -188,13 +192,14 @@ export default function LiveRadar() { // Map const [activeMapKey, setActiveMapKey] = useState(null) - // Spieler + // Spieler-live const playersRef = useRef>(new Map()) const [players, setPlayers] = useState([]) + const [hoveredPlayerId, setHoveredPlayerId] = useState(null) // Deaths - const deathSeqRef = useRef(0); - const deathSeenRef = useRef>(new Set()); + const deathSeqRef = useRef(0) + const deathSeenRef = useRef>(new Set()) // Grenaden + Trails const grenadesRef = useRef>(new Map()) @@ -210,6 +215,21 @@ export default function LiveRadar() { const bombRef = useRef(null) const [bomb, setBomb] = useState(null) + // Avatare: Store (lädt /api/user/[steamId]) + const ensureAvatars = useAvatarDirectoryStore(s => s.ensureLoaded) + const avatarVersion = useAvatarDirectoryStore(s => s.version) // Re-Render wenn Avatare kommen + const avatarById = useAvatarDirectoryStore(s => s.byId) + + // Toggle: Avatare statt Icons + const [useAvatars, setUseAvatars] = useState(false) + useEffect(() => { try { setUseAvatars(localStorage.getItem('radar.useAvatars') === '1') } catch {} }, []) + useEffect(() => { try { localStorage.setItem('radar.useAvatars', useAvatars ? '1' : '0') } catch {} }, [useAvatars]) + + // Spieler-IDs → Avatare laden (Store dedupliziert/limitiert) + useEffect(() => { + if (players.length) ensureAvatars(players.map(p => p.id)) // p.id = SteamID + }, [players, ensureAvatars]) + // Flush const flushTimer = useRef(null) const scheduleFlush = () => { @@ -234,42 +254,34 @@ export default function LiveRadar() { } }, []) - // ersetzt deine bisherige clearRoundArtifacts + // clearRoundArtifacts const clearRoundArtifacts = (resetPlayers = false, hard = false) => { - // round/map visuals - deathMarkersRef.current = []; - deathSeenRef.current.clear(); - trailsRef.current.clear(); - grenadesRef.current.clear(); - bombRef.current = null; + deathMarkersRef.current = [] + deathSeenRef.current.clear() + trailsRef.current.clear() + grenadesRef.current.clear() + bombRef.current = null if (hard) { - // z.B. bei Mapwechsel: komplett leer - playersRef.current.clear(); + playersRef.current.clear() } else if (resetPlayers) { - // zum Rundenstart: alle wieder lebendig und ohne Bombe for (const [id, p] of playersRef.current) { - playersRef.current.set(id, { ...p, alive: true, hasBomb: false }); + playersRef.current.set(id, { ...p, alive: true, hasBomb: false }) } } - - scheduleFlush(); - }; - + scheduleFlush() + } useEffect(() => { - if (activeMapKey) clearRoundArtifacts(true, true); // vorher: nur Artefakte + if (activeMapKey) clearRoundArtifacts(true, true) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeMapKey]); - + }, [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 = steamIdOf(p) - if (!id) continue // ⬅️ wichtig: keine name-basierten Keys mehr + if (!id) continue const old = playersRef.current.get(id) playersRef.current.set(id, { id, @@ -286,7 +298,7 @@ export default function LiveRadar() { const handleMetaPlayerJoin = (p: any) => { const id = steamIdOf(p) - if (!id) return // ⬅️ keine Duplikate über Namen erzeugen + if (!id) return const old = playersRef.current.get(id) playersRef.current.set(id, { id, @@ -295,7 +307,7 @@ export default function LiveRadar() { x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0, yaw: old?.yaw ?? null, alive: true, - hasBomb: false, // ⬅️ safe default + hasBomb: false, }) scheduleFlush() } @@ -319,143 +331,93 @@ export default function LiveRadar() { function normalizeBomb(raw:any): BombState | null { if (!raw) return null - const payload = raw.bomb ?? raw.c4 ?? raw const pos = pickVec3(payload) const t = String(raw?.type ?? '').toLowerCase() - // Events, die nur den Beginn/Abbruch des Plantens signalisieren → ignorieren - if (t === 'bomb_beginplant' || t === 'bomb_abortplant') { - return null - } + if (t === 'bomb_beginplant' || t === 'bomb_abortplant') return null let status: BombState['status'] = 'unknown' const s = String(payload?.status ?? payload?.state ?? '').toLowerCase() - - // String-Status präzise auswerten if (s.includes('planted')) status = 'planted' - else if (s.includes('planting')) status = 'unknown' // bewusst NICHT anzeigen - else if (s.includes('drop')) status = 'dropped' - else if (s.includes('carry')) status = 'carried' - else if (s.includes('defus')) status = 'defusing' + else if (s.includes('planting')) status = 'unknown' + else if (s.includes('drop')) status = 'dropped' + else if (s.includes('carry')) status = 'carried' + else if (s.includes('defus')) status = 'defusing' - // Bool-Varianten if (payload?.planted === true) status = 'planted' if (payload?.dropped === true) status = 'dropped' if (payload?.carried === true) status = 'carried' if (payload?.defusing === true) status = 'defusing' if (payload?.defused === true) status = 'defused' - // Event-Typen - if (t === 'bomb_planted') status = 'planted' - if (t === 'bomb_dropped') status = 'dropped' - if (t === 'bomb_pickup') status = 'carried' + if (t === 'bomb_planted') status = 'planted' + if (t === 'bomb_dropped') status = 'dropped' + if (t === 'bomb_pickup') status = 'carried' if (t === 'bomb_begindefuse') status = 'defusing' if (t === 'bomb_abortdefuse') status = 'planted' - if (t === 'bomb_defused') status = 'defused' + if (t === 'bomb_defused') status = 'defused' - // Position kann bei Defuse-Events fehlen → später mit letzter Position mergen const x = Number.isFinite(pos.x) ? pos.x : NaN const y = Number.isFinite(pos.y) ? pos.y : NaN const z = Number.isFinite(pos.z) ? pos.z : NaN - return { x, y, z, status, changedAt: Date.now() } } - // Fallback: aus Spielerzustand ableiten (Bombenträger) const updateBombFromPlayers = () => { - // geplante Bombe hat Vorrang – nicht überschreiben if (bombRef.current?.status === 'planted') return - const carrier = Array.from(playersRef.current.values()).find(p => p.hasBomb) if (carrier) { - // Nur "carried" setzen, echte Dropposition kommt per Server-Event bombRef.current = { x: carrier.x, y: carrier.y, z: carrier.z, - status: 'carried', changedAt: bombRef.current?.status === 'carried' - ? (bombRef.current.changedAt) // plantedAt nicht antasten - : Date.now() + status: 'carried', + changedAt: bombRef.current?.status === 'carried' + ? bombRef.current.changedAt + : Date.now(), } } - // kein else → wir warten auf 'bomb_dropped' vom Server } /* ───────── Positions-Callbacks ───────── */ const addDeathMarker = (x: number, y: number, steamId?: string) => { - const now = Date.now(); - - // pro Runde nur ein Marker pro Spieler + const now = Date.now() if (steamId) { - if (deathSeenRef.current.has(steamId)) return; - deathSeenRef.current.add(steamId); + if (deathSeenRef.current.has(steamId)) return + deathSeenRef.current.add(steamId) } - - // interne eindeutige ID behalten (für Fallback) - const uid = `${steamId ?? 'd'}#${now}#${deathSeqRef.current++}`; - deathMarkersRef.current.push({ id: uid, sid: steamId ?? null, x, y, t: now }); - }; - + const uid = `${steamId ?? 'd'}#${now}#${deathSeqRef.current++}` + deathMarkersRef.current.push({ id: uid, sid: steamId ?? null, x, y, t: now }) + } function upsertPlayer(e: any) { - // 1) Stabile ID bestimmen – nur SteamID benutzen - const id = steamIdOf(e); - if (!id) return; // keine name-basierten Keys mehr + const id = steamIdOf(e); if (!id) return + const pos = e.pos ?? e.position ?? e.location ?? e.coordinates + const x = asNum(e.x ?? (Array.isArray(pos) ? pos?.[0] : pos?.x)) + const y = asNum(e.y ?? (Array.isArray(pos) ? pos?.[1] : pos?.y)) + const z = asNum(e.z ?? (Array.isArray(pos) ? pos?.[2] : pos?.z), 0) + if (!Number.isFinite(x) || !Number.isFinite(y)) return - // 2) Position herausziehen - const pos = e.pos ?? e.position ?? e.location ?? e.coordinates; - const x = asNum(e.x ?? (Array.isArray(pos) ? pos?.[0] : pos?.x)); - const y = asNum(e.y ?? (Array.isArray(pos) ? pos?.[1] : pos?.y)); - const z = asNum(e.z ?? (Array.isArray(pos) ? pos?.[2] : pos?.z), 0); - if (!Number.isFinite(x) || !Number.isFinite(y)) return; + const yawVal = e.yaw ?? e.viewAngle?.yaw ?? e.view?.yaw ?? e.aim?.yaw ?? e.ang?.y ?? e.angles?.y ?? e.rotation?.yaw + const yaw = Number(yawVal) + const old = playersRef.current.get(id) - // 3) Blickrichtung / Yaw - const yawVal = - e.yaw ?? - e.viewAngle?.yaw ?? - e.view?.yaw ?? - e.aim?.yaw ?? - e.ang?.y ?? - e.angles?.y ?? - e.rotation?.yaw; - const yaw = Number(yawVal); + let nextAlive: boolean | undefined = undefined + if (typeof e.alive === 'boolean') nextAlive = e.alive + else if (e.hp != null || e.health != null || e.state?.health != null) { + const hpProbe = asNum(e.hp ?? e.health ?? e.state?.health, NaN) + if (Number.isFinite(hpProbe)) nextAlive = hpProbe > 0 + } else nextAlive = old?.alive - // 4) Vorheriger Zustand - const old = playersRef.current.get(id); + const hp = asNum(e.hp ?? e.health ?? e.state?.health, old?.hp ?? null as any) + const armor = asNum(e.armor ?? e.state?.armor, old?.armor ?? null as any) + const helmet = (e.helmet ?? e.hasHelmet ?? e.state?.helmet) + const defuse = (e.defuse ?? e.hasDefuse ?? e.hasDefuser ?? e.state?.defusekit) - // 5) Alive-State bestimmen - // - e.alive (bool) hat Vorrang - // - sonst aus Health ableiten - let nextAlive: boolean | undefined = undefined; - if (typeof e.alive === 'boolean') { - nextAlive = e.alive; - } else if (e.hp != null || e.health != null || e.state?.health != null) { - const hpProbe = asNum(e.hp ?? e.health ?? e.state?.health, NaN); - if (Number.isFinite(hpProbe)) nextAlive = hpProbe > 0; - } else { - nextAlive = old?.alive; - } + const hasBombDetected = !!detectHasBomb(e) || !!old?.hasBomb + const hasBomb = bombRef.current?.status === 'planted' ? false : hasBombDetected - // 6) Armor/HP/Equipment - const hp = asNum(e.hp ?? e.health ?? e.state?.health, old?.hp ?? null as any); - const armor = asNum(e.armor ?? e.state?.armor, old?.armor ?? null as any); - const helmet = (e.helmet ?? e.hasHelmet ?? e.state?.helmet); - const defuse = (e.defuse ?? e.hasDefuse ?? e.hasDefuser ?? e.state?.defusekit); + if ((old?.alive !== false) && nextAlive === false) addDeathMarker(x, y, id) - // 7) Bombenstatus: - // - nie als „carried“ markieren, wenn die Bombe bereits planted ist - // - sonst detektieren, mit altem Zustand „kleben“ - const hasBombDetected = !!detectHasBomb(e) || !!old?.hasBomb; - const hasBomb = - bombRef.current?.status === 'planted' - ? false - : hasBombDetected; - - // 8) Death-Marker setzen, wenn gerade gestorben - if ((old?.alive !== false) && nextAlive === false) { - addDeathMarker(x, y, id); - } - - // 9) Schreiben/mergen playersRef.current.set(id, { id, name: e.name ?? old?.name ?? null, @@ -468,49 +430,83 @@ export default function LiveRadar() { armor: Number.isFinite(armor) ? armor : (old?.armor ?? null), helmet: typeof helmet === 'boolean' ? helmet : (old?.helmet ?? null), defuse: typeof defuse === 'boolean' ? defuse : (old?.defuse ?? null), - }); + }) } const handlePlayersAll = (msg: any) => { const ap = msg?.allplayers if (!ap || typeof ap !== 'object') return - let total = 0, aliveCount = 0 + let total = 0 + let aliveCount = 0 + for (const key of Object.keys(ap)) { const p = ap[key] - const pos = parseVec3String(p.position) + + // ✅ ID robust bestimmen: erst Payload, sonst Key (wenn 17-stellig) + const steamIdFromPayload = steamIdOf(p) + const keyLooksLikeSteamId = /^\d{17}$/.test(String(key)) + const id = steamIdFromPayload ?? (keyLooksLikeSteamId ? String(key) : null) + if (!id) continue + + // Position + const pos = parseVec3String(p.position ?? p.pos ?? p.location ?? p.coordinates) + + // Yaw aus forward-Vektor, sonst Fallbacks const fwd = parseVec3String(p.forward) - const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG) - const id = String(key) + let yawDeg = Number.NaN + if (Number.isFinite(fwd.x) && Number.isFinite(fwd.y) && (fwd.x !== 0 || fwd.y !== 0)) { + yawDeg = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG) + } else { + const yawProbe = asNum( + p?.yaw ?? p?.viewAngle?.yaw ?? p?.view?.yaw ?? p?.aim?.yaw ?? p?.ang?.y ?? p?.angles?.y ?? p?.rotation?.yaw, + NaN + ) + if (Number.isFinite(yawProbe)) yawDeg = normalizeDeg(yawProbe) + } + 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 + + // Alive/HP/Armor/Equipment + const hpNum = Number(p?.state?.health) + const armorNum = Number(p?.state?.armor) + const isAlive = p?.state?.health != null ? p.state.health > 0 : (old?.alive ?? true) + const helmet = !!p?.state?.helmet + const defuse = !!p?.state?.defusekit + + // Bomben-Status const hasBomb = detectHasBomb(p) || old?.hasBomb - if ((old?.alive ?? true) && !isAlive) addDeathMarker(pos.x, pos.y, id) + // Death-Marker bei Death + if ((old?.alive ?? true) && !isAlive) { + addDeathMarker(pos.x, pos.y, id) + } + // Upsert playersRef.current.set(id, { id, - name: p.name ?? old?.name ?? null, - team: mapTeam(p.team ?? old?.team), + name: p?.name ?? old?.name ?? null, + team: mapTeam(p?.team ?? old?.team), x: pos.x, y: pos.y, z: pos.z, - yaw, + yaw: Number.isFinite(yawDeg) ? yawDeg : (old?.yaw ?? null), 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, + hp: Number.isFinite(hpNum) ? hpNum : (old?.hp ?? null), + armor: Number.isFinite(armorNum) ? armorNum : (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)) { + // Runde frisch (alle leben) → alte Artefakte weg + if ( + total > 0 && + aliveCount === total && + (deathMarkersRef.current.length > 0 || trailsRef.current.size > 0 || grenadesRef.current.size > 0) + ) { clearRoundArtifacts() } @@ -589,7 +585,6 @@ export default function LiveRadar() { const handleGrenades = (g: any) => { const list = normalizeGrenades(g) - // Trails updaten const seen = new Set() const now = Date.now() for (const it of list) { @@ -604,14 +599,10 @@ export default function LiveRadar() { 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) - } + if (!seen.has(id) && now - tr.lastSeen > UI.trail.fadeMs) trailsRef.current.delete(id) } - // aktuelle Nades übernehmen const next = new Map() for (const it of list) next.set(it.id, it) grenadesRef.current = next @@ -619,7 +610,6 @@ export default function LiveRadar() { scheduleFlush() } - // erster Flush useEffect(() => { if (!playersRef.current && !grenadesRef.current) return scheduleFlush() @@ -762,52 +752,45 @@ export default function LiveRadar() { }, [imgSize, overview]) // ── Bomb "beep" / pulse timing ────────────────────────────── - const BOMB_FUSE_MS = 40_000; - const plantedAtRef = useRef(null); - const beepTimerRef = useRef(null); - const [beepState, setBeepState] = useState<{ key: number; dur: number } | null>(null); + const BOMB_FUSE_MS = 40_000 + const plantedAtRef = useRef(null) + const beepTimerRef = useRef(null) + const [beepState, setBeepState] = useState<{ key: number; dur: number } | null>(null) const getBeepIntervalMs = (remainingMs: number) => { - const s = remainingMs / 1000; - if (s > 30) return 1000; - if (s > 20) return 900; - if (s > 10) return 800; - if (s > 5) return 700; - return 500; - }; + const s = remainingMs / 1000 + if (s > 30) return 1000 + if (s > 20) return 900 + if (s > 10) return 800 + if (s > 5) return 700 + return 500 + } const stopBeep = () => { - if (beepTimerRef.current != null) window.clearTimeout(beepTimerRef.current); - beepTimerRef.current = null; - plantedAtRef.current = null; - setBeepState(null); - }; + if (beepTimerRef.current != null) window.clearTimeout(beepTimerRef.current) + beepTimerRef.current = null + plantedAtRef.current = null + setBeepState(null) + } - const isBeepActive = !!bomb && (bomb.status === 'planted' || bomb.status === 'defusing'); + const isBeepActive = !!bomb && (bomb.status === 'planted' || bomb.status === 'defusing') useEffect(() => { - if (!isBeepActive) { stopBeep(); return; } - - // nur initial (beim Plant) Startzeit setzen + if (!isBeepActive) { stopBeep(); return } if (!plantedAtRef.current) { - // wenn wir aus "defusing" kommen, wollen wir NICHT resetten → changedAt beim Plant bleibt erhalten - plantedAtRef.current = bomb!.changedAt; - + plantedAtRef.current = bomb!.changedAt const tick = () => { - if (!plantedAtRef.current) return; - const elapsed = Date.now() - plantedAtRef.current; - const remaining = Math.max(0, BOMB_FUSE_MS - elapsed); - if (remaining <= 0) { stopBeep(); return; } - - const dur = getBeepIntervalMs(remaining); - setBeepState(prev => ({ key: (prev?.key ?? 0) + 1, dur })); - beepTimerRef.current = window.setTimeout(tick, dur); - }; - - tick(); // erster Ping sofort + if (!plantedAtRef.current) return + const elapsed = Date.now() - plantedAtRef.current + const remaining = Math.max(0, BOMB_FUSE_MS - elapsed) + if (remaining <= 0) { stopBeep(); return } + const dur = getBeepIntervalMs(remaining) + setBeepState(prev => ({ key: (prev?.key ?? 0) + 1, dur })) + beepTimerRef.current = window.setTimeout(tick, dur) + } + tick() } - }, [isBeepActive]); - + }, [isBeepActive, bomb]) /* ───────── Status-Badge ───────── */ const WsDot = ({ status, label }: { status: WsStatus, label: string }) => { @@ -830,16 +813,28 @@ export default function LiveRadar() { ) } - /* ───────── Render (Fix A: reines Flex-Layout) ───────── */ + /* ───────── Render ───────── */ return (
{/* Header */} -
-

Live Radar

-
-
- {activeMapKey ? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase() : '—'} -
+
+ {/* links */} +

Live Radar

+ + {/* mitte: Switch zentriert */} +
+ +
+ + {/* rechts: Status */} +
@@ -862,42 +857,31 @@ export default function LiveRadar() { onPlayersAll={(m)=> { handlePlayersAll(m); scheduleFlush() }} onGrenades={(g)=> { handleGrenades(g); scheduleFlush() }} - // ⬇️ wichtig: sofort alles aufräumen + Spieler lebend/ohne Bombe - onRoundStart={() => { clearRoundArtifacts(true); }} + onRoundStart={() => { clearRoundArtifacts(true) }} - // optional: am Round-End gleich aufräumen (nimmt die Leichen sofort weg) onRoundEnd={() => { - // Spieler zurücksetzen for (const [id, p] of playersRef.current) { playersRef.current.set(id, { ...p, hasBomb: false }) } - - // Bombenping aus, Marker ggf. als 'defused' stehen lassen if (bombRef.current?.status === 'planted') { bombRef.current = { ...bombRef.current, status: 'defused' } } stopBeep() - - // visuelle Artefakte (Trails, Nades, Deaths) aufräumen – Bombe NICHT löschen deathMarkersRef.current = [] trailsRef.current.clear() grenadesRef.current.clear() - scheduleFlush() }} - onBomb={(b)=> { const prev = bombRef.current const nb = normalizeBomb(b) if (!nb) return - const withPos = { x: Number.isFinite(nb.x) ? nb.x : (prev?.x ?? 0), y: Number.isFinite(nb.y) ? nb.y : (prev?.y ?? 0), z: Number.isFinite(nb.z) ? nb.z : (prev?.z ?? 0), } - const sameStatus = prev && prev.status === nb.status bombRef.current = { ...withPos, @@ -908,7 +892,7 @@ export default function LiveRadar() { }} /> - {/* Inhalt: 3-Spalten-Layout (T | Radar | CT) */} + {/* Inhalt */}
{!activeMapKey ? (
@@ -928,13 +912,18 @@ export default function LiveRadar() { defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive })) } + // Optional, falls deine TeamSidebar Avatare nutzt: + // @ts-ignore + showAvatars={useAvatars} + // @ts-ignore + avatarsById={avatarById} + onHoverPlayer={setHoveredPlayerId} /> {/* Center: Radar */}
{currentSrc ? (
- {/* Bild füllt Container (Letterboxing via object-contain) */} - {/* Overlay skaliert deckungsgleich zum Bild */} + {/* Map-Title overlay (zentriert) */} + {activeMapKey && ( +
+ {activeMapKey.replace(/^de_/, '').replace(/_/g, ' ')} +
+ )} + + {/* Overlay */} {imgSize && ( { const showBomb = bomb.status === 'planted' || bomb.status === 'defusing' || bomb.status === 'defused' if (!showBomb) return null - const P = worldToPx(bomb.x, bomb.y) if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null - // Mindestgrößen in Pixeln erzwingen - const rBase = Math.max(10, unitsToPx(28)) // Kreis darf klein sein, aber nicht <10px - const iconSize = Math.max(24, rBase * 1.8) // SVG-Icon mindestens 24px - + const rBase = Math.max(10, unitsToPx(28)) + const iconSize = Math.max(24, rBase * 1.8) const isActive = bomb.status === 'planted' || bomb.status === 'defusing' const isDefused = bomb.status === 'defused' - const color = isDefused ? '#10b981' : (isActive ? '#ef4444' : '#dddddd') + const iconColor = bomb.status === 'planted' ? '#ef4444' : (isDefused ? '#10b981' : '#e5e7eb') + const maskId = `bomb-mask-${bomb.changedAt}` return ( {isActive && beepState && ( - + )} - + + + + + + + + ) })()} - {/* Spieler */} {players .filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false) .map((p) => { + void avatarVersion 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 + const base = Math.min(imgSize!.w, imgSize!.h) + const rBase = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel) + const stroke = p.hasBomb ? UI.player.bombStroke : UI.player.stroke + const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT + + // Blickrichtung … let dxp = 0, dyp = 0 if (Number.isFinite(p.yaw as number)) { const yawRad = (Number(p.yaw) * Math.PI) / 180 @@ -1079,37 +1092,162 @@ export default function LiveRadar() { dxp = B.x - A.x dyp = B.y - A.y const cur = Math.hypot(dxp, dyp) || 1 + const dirLenPx = Math.max(UI.player.dirMinLenPx, rBase * UI.player.dirLenRel) dxp *= dirLenPx / cur dyp *= dirLenPx / cur } + // Avatar-URL (mit Fallback) + const entry = avatarById[p.id] as any + const avatarFromStore = entry && !entry?.notFound && entry?.avatar ? entry.avatar : null + const avatarUrl = useAvatars ? (avatarFromStore || DEFAULT_AVATAR) : null + + // ➜ Avatare größer skalieren + const isAvatar = !!avatarUrl + const r = isAvatar + ? rBase * UI.player.avatarScale + : rBase * (UI.player.iconScale ?? 1) + + const strokeW = Math.max(1, r * UI.player.lineWidthRel) + const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor + const clipId = `clip-ava-${p.id}` + + const ringColor = (isAvatar && p.hasBomb) ? UI.player.bombStroke : fillColor + return ( - - {Number.isFinite(p.yaw as number) && ( - + + + + + + + { + const img = e.currentTarget as SVGImageElement + if (!img.getAttribute('data-fallback')) { + img.setAttribute('data-fallback', '1') + img.setAttribute('href', DEFAULT_AVATAR) + } + }} + /> + + {/* Team-/Bomben-Ring */} + + + {p.id === hoveredPlayerId && ( + + {/* dezenter statischer Ring */} + + {/* pulsierender Ping */} + + + )} + + {/* Zusatz: kleiner roter Außenring, wenn Bombe getragen wird */} + {p.hasBomb && ( + + )} + + ) : ( + // Icons (wenn Avatare aus) + )} + + {Number.isFinite(p.yaw as number) && ( + useAvatars && isAvatar ? ( + // Blickrichtung als kurzer Bogen IM Rand + (() => { + // Richtung aus dem bereits berechneten dxp/dyp ableiten: + const angle = Math.atan2(dyp, dxp) // im SVG-Koordinatensystem + const spread = (UI.player.avatarDirArcDeg ?? 28) * Math.PI / 180 + const a1 = angle - spread / 2 + const a2 = angle + spread / 2 + + const x1 = A.x + Math.cos(a1) * r + const y1 = A.y + Math.sin(a1) * r + const x2 = A.x + Math.cos(a2) * r + const y2 = A.y + Math.sin(a2) * r + + // Ringbreite als Strichstärke nutzen, damit es "im Rand" liegt + const ringW = Math.max(1.2, r * UI.player.avatarRingWidthRel) + return ( + + ) + })() + ) : ( + // Fallback: klassische Linie vom Mittelpunkt (wenn Avatare AUS) + + ) + )} ) - })} + }) + } - {/* Death-Marker (SVG statt "X") */} + {/* Death-Marker */} {deathMarkers.map(dm => { const P = worldToPx(dm.x, dm.y) - const size = Math.max(10, UI.death.sizePx) // z.B. 16–20 für bessere Sichtbarkeit - const key = dm.sid ? `death-${dm.sid}` : `death-${dm.id}`; // Fallback, falls mal keine SID vorliegt + const size = Math.max(10, UI.death.sizePx) + const key = dm.sid ? `death-${dm.sid}` : `death-${dm.id}` return (
)}
+
diff --git a/src/app/components/radar/TeamSidebar.tsx b/src/app/components/radar/TeamSidebar.tsx index 4da0960..27a2d52 100644 --- a/src/app/components/radar/TeamSidebar.tsx +++ b/src/app/components/radar/TeamSidebar.tsx @@ -1,9 +1,11 @@ +// TeamSidebar.tsx 'use client' -import React from 'react' +import React, { useEffect } from 'react' +import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore' export type Team = 'T' | 'CT' export type SidebarPlayer = { - id: string + id: string // <- SteamID name?: string | null hp?: number | null armor?: number | null @@ -17,18 +19,32 @@ export default function TeamSidebar({ team, players, align = 'left', + onHoverPlayer, }: { team: Team players: SidebarPlayer[] align?: 'left' | 'right' + onHoverPlayer?: (id: string | null) => void }) { - const teamName = team === 'CT' ? 'Counter-Terrorists' : 'Terrorists' + // Avatar-Directory + const ensureAvatars = useAvatarDirectoryStore(s => s.ensureLoaded) + const avatarById = useAvatarDirectoryStore(s => s.byId) + const avatarVer = useAvatarDirectoryStore(s => s.version) // re-render trigger + + // bei Änderungen nachladen (sicher ist sicher; LiveRadar lädt auch) + useEffect(() => { + if (players.length) ensureAvatars(players.map(p => p.id)) + }, [players, ensureAvatars]) + + const teamName = team === 'CT' ? 'Counter-Terrorists' : 'Terrorists' const teamColor = team === 'CT' ? 'text-blue-400' : 'text-amber-400' - const barArmor = team === 'CT' ? 'bg-blue-500' : 'bg-amber-500' + const barArmor = team === 'CT' ? 'bg-blue-500' : 'bg-amber-500' + const ringColor = team === 'CT' ? 'ring-blue-500' : 'ring-amber-500' + const isRight = align === 'right' + const aliveCount = players.filter(p => p.alive !== false && (p.hp ?? 1) > 0).length const sorted = [...players].sort((a, b) => { - // alive first, then hp desc, then name const al = (b.alive ? 1 : 0) - (a.alive ? 1 : 0) if (al !== 0) return al const hp = (b.hp ?? -1) - (a.hp ?? -1) @@ -43,31 +59,69 @@ export default function TeamSidebar({ {aliveCount}/{players.length}
-
+
{sorted.map(p => { - const hp = clamp(p.alive === false ? 0 : p.hp ?? 100, 0, 100) + void avatarVer // re-render, wenn Avatare eintrudeln + const hp = clamp(p.alive === false ? 0 : p.hp ?? 100, 0, 100) const armor = clamp(p.armor ?? 0, 0, 100) - const dead = p.alive === false || hp <= 0 + const dead = p.alive === false || hp <= 0 + + // Avatar aus Store (Fallback: Default) + const entry = avatarById[p.id] as any + const avatarUrl = + entry && !(entry as any)?.notFound && entry?.avatar + ? entry.avatar + : '/assets/img/avatars/default_steam_avatar.jpg' + + // Layout: Avatar neben Stack(Name+Bars); rechts gespiegelt bei align="right" + const rowDir = isRight ? 'flex-row-reverse text-right' : 'flex-row' + const stackAlg = isRight ? 'items-end' : 'items-start' return ( -
-
- -
{p.name || p.id}
- {/* kleine Status-Icons */} - {p.hasBomb && team === 'T' && 💣} - {p.helmet && 🪖} - {p.defuse && team === 'CT' && 🗝️} -
{hp}
-
+
onHoverPlayer?.(p.id)} + onMouseLeave={() => onHoverPlayer?.(null)} + //className={`rounded-md px-2 py-2 bg-neutral-800/40 ${dead ? 'opacity-60' : ''}`} + className={`rounded-md px-2 py-2 transition cursor-pointer + bg-neutral-800/40 hover:bg-neutral-700/40 + hover:ring-2 hover:ring-white/20 + ${dead ? 'opacity-60' : ''}`} + > +
+ {/* Avatar groß + Team-Ring */} + {p.name - {/* HP-Bar */} -
-
-
- {/* Armor-Bar */} -
-
+ {/* Stack: Name + Icons, darunter die größeren Balken */} +
+ {/* Kopfzeile */} +
+ {p.name || p.id} + {p.hasBomb && team === 'T' && 💣} + {p.helmet && 🪖} + {p.defuse && team === 'CT' && 🗝️} + {hp} +
+ + {/* Größere Balken */} +
+ {/* HP */} +
+
+
+ {/* Armor */} +
+
+
+
+
) diff --git a/src/app/lib/useAvatarDirectoryStore.ts b/src/app/lib/useAvatarDirectoryStore.ts new file mode 100644 index 0000000..ef69877 --- /dev/null +++ b/src/app/lib/useAvatarDirectoryStore.ts @@ -0,0 +1,99 @@ +// app/lib/useAvatarDirectoryStore.ts +'use client' +import { create } from 'zustand' + +export type AvatarUser = { + steamId: string + name?: string | null + avatar?: string | null + status?: 'online' | 'away' | 'offline' + lastActiveAt?: string | null +} + +type NotFound = { steamId: string; notFound: true } + +type Store = { + byId: Record + loading: Set + version: number + upsert: (u: AvatarUser | NotFound) => void + ensureLoaded: (steamIds: string[]) => Promise +} + +const isValidSteamId = (s: string) => /^\d{17}$/.test(s) + +export const useAvatarDirectoryStore = create((set, get) => ({ + byId: {}, + loading: new Set(), + version: 0, + + upsert: (u) => + set((s) => { + const prev = s.byId[u.steamId] + const changed = + !prev || + ('notFound' in u) !== ('notFound' in (prev as any)) || + (u as any).avatar !== (prev as any)?.avatar || + (u as any).name !== (prev as any)?.name || + (u as any).status !== (prev as any)?.status || + (u as any).lastActiveAt !== (prev as any)?.lastActiveAt + + if (!changed) return s + return { byId: { ...s.byId, [u.steamId]: u }, version: s.version + 1 } + }), + + ensureLoaded: async (steamIds: string[]) => { + const state = get() + const unique = Array.from(new Set(steamIds.map(String))) + const need = unique + .filter(Boolean) + .filter(isValidSteamId) // ✅ nur echte SteamIDs + .filter((id) => !state.byId[id] && !state.loading.has(id)) + + if (need.length === 0) return + + set((s) => { + const loading = new Set(s.loading) + need.forEach((id) => loading.add(id)) + return { loading } + }) + + try { + const pool = 5 + let i = 0 + const runNext = async (): Promise => { + const idx = i++ + if (idx >= need.length) return + const steamId = need[idx] + try { + const r = await fetch(`/api/user/${encodeURIComponent(steamId)}`, { cache: 'no-store' }) + if (r.status === 404) { + get().upsert({ steamId, notFound: true }) + } else if (r.ok) { + const json = await r.json() + const u = (json?.user ?? {}) as Partial + get().upsert({ + steamId, + name: u.name ?? null, + avatar: u.avatar ?? null, + status: (u.status as any) ?? undefined, + lastActiveAt: (u as any)?.lastActiveAt ?? null, + }) + } + } catch { + // Netzfehler ignorieren → später erneut versuchen + } finally { + await runNext() + } + } + + await Promise.all(Array.from({ length: Math.min(pool, need.length) }, () => runNext())) + } finally { + set((s) => { + const loading = new Set(s.loading) + need.forEach((id) => loading.delete(id)) + return { loading } + }) + } + }, +}))