This commit is contained in:
Linrador 2025-09-10 23:33:10 +02:00
parent d9911012b7
commit 3c68c3ad2c
3 changed files with 573 additions and 271 deletions

View File

@ -1,9 +1,12 @@
// src/app/components/radar/LiveRadar.tsx
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import MetaSocket from './MetaSocket' import MetaSocket from './MetaSocket'
import PositionsSocket from './PositionsSocket' import PositionsSocket from './PositionsSocket'
import TeamSidebar from './TeamSidebar' import TeamSidebar from './TeamSidebar'
import Switch from '../Switch'
import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore'
/* ───────── UI config ───────── */ /* ───────── UI config ───────── */
const UI = { const UI = {
@ -18,6 +21,10 @@ const UI = {
fillCT: '#3b82f6', fillCT: '#3b82f6',
fillT: '#f59e0b', fillT: '#f59e0b',
dirColor: 'auto' as 'auto' | string, 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: { nade: {
stroke: '#111111', stroke: '#111111',
@ -33,7 +40,7 @@ const UI = {
death: { death: {
stroke: '#9ca3af', stroke: '#9ca3af',
lineWidthPx: 2, lineWidthPx: 2,
sizePx: 20, sizePx: 24,
}, },
trail: { trail: {
maxPoints: 60, maxPoints: 60,
@ -44,14 +51,12 @@ const UI = {
} }
/* ───────── helpers ───────── */ /* ───────── helpers ───────── */
const steamIdOf = (src:any): string | null => { const steamIdOf = (src:any): string | null => {
const raw = src?.steamId ?? src?.steam_id ?? src?.steamid const raw = src?.steamId ?? src?.steam_id ?? src?.steamid
const s = raw != null ? String(raw) : '' const s = raw != null ? String(raw) : ''
return s && s !== '0' ? s : null return s && s !== '0' ? s : null
} }
function contrastStroke(hex: string) { function contrastStroke(hex: string) {
const h = hex.replace('#','') const h = hex.replace('#','')
const r = parseInt(h.slice(0,2),16)/255 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 { function detectHasBomb(src: any): boolean {
const flags = [ const flags = ['hasBomb','has_bomb','bomb','c4','hasC4','carryingBomb','bombCarrier','isBombCarrier']
'hasBomb','has_bomb','bomb','c4','hasC4','carryingBomb','bombCarrier','isBombCarrier'
]
for (const k of flags) { for (const k of flags) {
if (typeof src?.[k] === 'boolean') return !!src[k] if (typeof src?.[k] === 'boolean') return !!src[k]
if (typeof src?.[k] === 'string') { 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_PATH,
process.env.NEXT_PUBLIC_CS2_META_WS_SCHEME process.env.NEXT_PUBLIC_CS2_META_WS_SCHEME
) )
const posUrl = makeWsUrl( const posUrl = makeWsUrl(
process.env.NEXT_PUBLIC_CS2_POS_WS_HOST, process.env.NEXT_PUBLIC_CS2_POS_WS_HOST,
process.env.NEXT_PUBLIC_CS2_POS_WS_PORT, process.env.NEXT_PUBLIC_CS2_POS_WS_PORT,
@ -128,6 +130,8 @@ const posUrl = makeWsUrl(
process.env.NEXT_PUBLIC_CS2_POS_WS_SCHEME process.env.NEXT_PUBLIC_CS2_POS_WS_SCHEME
) )
const DEFAULT_AVATAR = '/assets/img/avatars/default_steam_avatar.jpg'
const RAD2DEG = 180 / Math.PI const RAD2DEG = 180 / Math.PI
const normalizeDeg = (d: number) => (d % 360 + 360) % 360 const normalizeDeg = (d: number) => (d % 360 + 360) % 360
const parseVec3String = (str?: string) => { const parseVec3String = (str?: string) => {
@ -188,13 +192,14 @@ export default function LiveRadar() {
// Map // Map
const [activeMapKey, setActiveMapKey] = useState<string | null>(null) const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
// Spieler // Spieler-live
const playersRef = useRef<Map<string, PlayerState>>(new Map()) const playersRef = useRef<Map<string, PlayerState>>(new Map())
const [players, setPlayers] = useState<PlayerState[]>([]) const [players, setPlayers] = useState<PlayerState[]>([])
const [hoveredPlayerId, setHoveredPlayerId] = useState<string | null>(null)
// Deaths // Deaths
const deathSeqRef = useRef(0); const deathSeqRef = useRef(0)
const deathSeenRef = useRef<Set<string>>(new Set()); const deathSeenRef = useRef<Set<string>>(new Set())
// Grenaden + Trails // Grenaden + Trails
const grenadesRef = useRef<Map<string, Grenade>>(new Map()) const grenadesRef = useRef<Map<string, Grenade>>(new Map())
@ -210,6 +215,21 @@ export default function LiveRadar() {
const bombRef = useRef<BombState | null>(null) const bombRef = useRef<BombState | null>(null)
const [bomb, setBomb] = useState<BombState | null>(null) const [bomb, setBomb] = useState<BombState | null>(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 // Flush
const flushTimer = useRef<number | null>(null) const flushTimer = useRef<number | null>(null)
const scheduleFlush = () => { const scheduleFlush = () => {
@ -234,42 +254,34 @@ export default function LiveRadar() {
} }
}, []) }, [])
// ersetzt deine bisherige clearRoundArtifacts // clearRoundArtifacts
const clearRoundArtifacts = (resetPlayers = false, hard = false) => { const clearRoundArtifacts = (resetPlayers = false, hard = false) => {
// round/map visuals deathMarkersRef.current = []
deathMarkersRef.current = []; deathSeenRef.current.clear()
deathSeenRef.current.clear(); trailsRef.current.clear()
trailsRef.current.clear(); grenadesRef.current.clear()
grenadesRef.current.clear(); bombRef.current = null
bombRef.current = null;
if (hard) { if (hard) {
// z.B. bei Mapwechsel: komplett leer playersRef.current.clear()
playersRef.current.clear();
} else if (resetPlayers) { } else if (resetPlayers) {
// zum Rundenstart: alle wieder lebendig und ohne Bombe
for (const [id, p] of playersRef.current) { 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(() => { useEffect(() => {
if (activeMapKey) clearRoundArtifacts(true, true); // vorher: nur Artefakte if (activeMapKey) clearRoundArtifacts(true, true)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeMapKey]); }, [activeMapKey])
/* ───────── Meta-Callbacks ───────── */ /* ───────── Meta-Callbacks ───────── */
const handleMetaMap = (key: string) => setActiveMapKey(key.toLowerCase())
const handleMetaPlayersSnapshot = (list: Array<{ steamId: string|number; name?: string; team?: any }>) => { const handleMetaPlayersSnapshot = (list: Array<{ steamId: string|number; name?: string; team?: any }>) => {
for (const p of list) { for (const p of list) {
const id = steamIdOf(p) const id = steamIdOf(p)
if (!id) continue // ⬅️ wichtig: keine name-basierten Keys mehr if (!id) continue
const old = playersRef.current.get(id) const old = playersRef.current.get(id)
playersRef.current.set(id, { playersRef.current.set(id, {
id, id,
@ -286,7 +298,7 @@ export default function LiveRadar() {
const handleMetaPlayerJoin = (p: any) => { const handleMetaPlayerJoin = (p: any) => {
const id = steamIdOf(p) const id = steamIdOf(p)
if (!id) return // ⬅️ keine Duplikate über Namen erzeugen if (!id) return
const old = playersRef.current.get(id) const old = playersRef.current.get(id)
playersRef.current.set(id, { playersRef.current.set(id, {
id, id,
@ -295,7 +307,7 @@ export default function LiveRadar() {
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0, x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null, yaw: old?.yaw ?? null,
alive: true, alive: true,
hasBomb: false, // ⬅️ safe default hasBomb: false,
}) })
scheduleFlush() scheduleFlush()
} }
@ -319,143 +331,93 @@ export default function LiveRadar() {
function normalizeBomb(raw:any): BombState | null { function normalizeBomb(raw:any): BombState | null {
if (!raw) return null if (!raw) return null
const payload = raw.bomb ?? raw.c4 ?? raw const payload = raw.bomb ?? raw.c4 ?? raw
const pos = pickVec3(payload) const pos = pickVec3(payload)
const t = String(raw?.type ?? '').toLowerCase() 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' let status: BombState['status'] = 'unknown'
const s = String(payload?.status ?? payload?.state ?? '').toLowerCase() const s = String(payload?.status ?? payload?.state ?? '').toLowerCase()
// String-Status präzise auswerten
if (s.includes('planted')) status = 'planted' if (s.includes('planted')) status = 'planted'
else if (s.includes('planting')) status = 'unknown' // bewusst NICHT anzeigen else if (s.includes('planting')) status = 'unknown'
else if (s.includes('drop')) status = 'dropped' else if (s.includes('drop')) status = 'dropped'
else if (s.includes('carry')) status = 'carried' else if (s.includes('carry')) status = 'carried'
else if (s.includes('defus')) status = 'defusing' else if (s.includes('defus')) status = 'defusing'
// Bool-Varianten
if (payload?.planted === true) status = 'planted' if (payload?.planted === true) status = 'planted'
if (payload?.dropped === true) status = 'dropped' if (payload?.dropped === true) status = 'dropped'
if (payload?.carried === true) status = 'carried' if (payload?.carried === true) status = 'carried'
if (payload?.defusing === true) status = 'defusing' if (payload?.defusing === true) status = 'defusing'
if (payload?.defused === true) status = 'defused' if (payload?.defused === true) status = 'defused'
// Event-Typen if (t === 'bomb_planted') status = 'planted'
if (t === 'bomb_planted') status = 'planted' if (t === 'bomb_dropped') status = 'dropped'
if (t === 'bomb_dropped') status = 'dropped' if (t === 'bomb_pickup') status = 'carried'
if (t === 'bomb_pickup') status = 'carried'
if (t === 'bomb_begindefuse') status = 'defusing' if (t === 'bomb_begindefuse') status = 'defusing'
if (t === 'bomb_abortdefuse') status = 'planted' 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 x = Number.isFinite(pos.x) ? pos.x : NaN
const y = Number.isFinite(pos.y) ? pos.y : NaN const y = Number.isFinite(pos.y) ? pos.y : NaN
const z = Number.isFinite(pos.z) ? pos.z : NaN const z = Number.isFinite(pos.z) ? pos.z : NaN
return { x, y, z, status, changedAt: Date.now() } return { x, y, z, status, changedAt: Date.now() }
} }
// Fallback: aus Spielerzustand ableiten (Bombenträger)
const updateBombFromPlayers = () => { const updateBombFromPlayers = () => {
// geplante Bombe hat Vorrang nicht überschreiben
if (bombRef.current?.status === 'planted') return if (bombRef.current?.status === 'planted') return
const carrier = Array.from(playersRef.current.values()).find(p => p.hasBomb) const carrier = Array.from(playersRef.current.values()).find(p => p.hasBomb)
if (carrier) { if (carrier) {
// Nur "carried" setzen, echte Dropposition kommt per Server-Event
bombRef.current = { bombRef.current = {
x: carrier.x, y: carrier.y, z: carrier.z, x: carrier.x, y: carrier.y, z: carrier.z,
status: 'carried', changedAt: bombRef.current?.status === 'carried' status: 'carried',
? (bombRef.current.changedAt) // plantedAt nicht antasten changedAt: bombRef.current?.status === 'carried'
: Date.now() ? bombRef.current.changedAt
: Date.now(),
} }
} }
// kein else → wir warten auf 'bomb_dropped' vom Server
} }
/* ───────── Positions-Callbacks ───────── */ /* ───────── Positions-Callbacks ───────── */
const addDeathMarker = (x: number, y: number, steamId?: string) => { const addDeathMarker = (x: number, y: number, steamId?: string) => {
const now = Date.now(); const now = Date.now()
// pro Runde nur ein Marker pro Spieler
if (steamId) { if (steamId) {
if (deathSeenRef.current.has(steamId)) return; if (deathSeenRef.current.has(steamId)) return
deathSeenRef.current.add(steamId); deathSeenRef.current.add(steamId)
} }
const uid = `${steamId ?? 'd'}#${now}#${deathSeqRef.current++}`
// interne eindeutige ID behalten (für Fallback) 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) { function upsertPlayer(e: any) {
// 1) Stabile ID bestimmen nur SteamID benutzen const id = steamIdOf(e); if (!id) return
const id = steamIdOf(e); const pos = e.pos ?? e.position ?? e.location ?? e.coordinates
if (!id) return; // keine name-basierten Keys mehr 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 yawVal = e.yaw ?? e.viewAngle?.yaw ?? e.view?.yaw ?? e.aim?.yaw ?? e.ang?.y ?? e.angles?.y ?? e.rotation?.yaw
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates; const yaw = Number(yawVal)
const x = asNum(e.x ?? (Array.isArray(pos) ? pos?.[0] : pos?.x)); const old = playersRef.current.get(id)
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;
// 3) Blickrichtung / Yaw let nextAlive: boolean | undefined = undefined
const yawVal = if (typeof e.alive === 'boolean') nextAlive = e.alive
e.yaw ?? else if (e.hp != null || e.health != null || e.state?.health != null) {
e.viewAngle?.yaw ?? const hpProbe = asNum(e.hp ?? e.health ?? e.state?.health, NaN)
e.view?.yaw ?? if (Number.isFinite(hpProbe)) nextAlive = hpProbe > 0
e.aim?.yaw ?? } else nextAlive = old?.alive
e.ang?.y ??
e.angles?.y ??
e.rotation?.yaw;
const yaw = Number(yawVal);
// 4) Vorheriger Zustand const hp = asNum(e.hp ?? e.health ?? e.state?.health, old?.hp ?? null as any)
const old = playersRef.current.get(id); 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 const hasBombDetected = !!detectHasBomb(e) || !!old?.hasBomb
// - e.alive (bool) hat Vorrang const hasBomb = bombRef.current?.status === 'planted' ? false : hasBombDetected
// - 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;
}
// 6) Armor/HP/Equipment if ((old?.alive !== false) && nextAlive === false) addDeathMarker(x, y, 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);
// 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, { playersRef.current.set(id, {
id, id,
name: e.name ?? old?.name ?? null, name: e.name ?? old?.name ?? null,
@ -468,49 +430,83 @@ export default function LiveRadar() {
armor: Number.isFinite(armor) ? armor : (old?.armor ?? null), armor: Number.isFinite(armor) ? armor : (old?.armor ?? null),
helmet: typeof helmet === 'boolean' ? helmet : (old?.helmet ?? null), helmet: typeof helmet === 'boolean' ? helmet : (old?.helmet ?? null),
defuse: typeof defuse === 'boolean' ? defuse : (old?.defuse ?? null), defuse: typeof defuse === 'boolean' ? defuse : (old?.defuse ?? null),
}); })
} }
const handlePlayersAll = (msg: any) => { const handlePlayersAll = (msg: any) => {
const ap = msg?.allplayers const ap = msg?.allplayers
if (!ap || typeof ap !== 'object') return if (!ap || typeof ap !== 'object') return
let total = 0, aliveCount = 0 let total = 0
let aliveCount = 0
for (const key of Object.keys(ap)) { for (const key of Object.keys(ap)) {
const p = ap[key] 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 fwd = parseVec3String(p.forward)
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG) let yawDeg = Number.NaN
const id = String(key) 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 old = playersRef.current.get(id)
const isAlive = p.state?.health > 0 || p.state?.health == null
const hp = Number(p.state?.health) // Alive/HP/Armor/Equipment
const armor = Number(p.state?.armor) const hpNum = Number(p?.state?.health)
const helmet = !!p.state?.helmet const armorNum = Number(p?.state?.armor)
const defuse = !!p.state?.defusekit 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 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, { playersRef.current.set(id, {
id, id,
name: p.name ?? old?.name ?? null, name: p?.name ?? old?.name ?? null,
team: mapTeam(p.team ?? old?.team), team: mapTeam(p?.team ?? old?.team),
x: pos.x, y: pos.y, z: pos.z, x: pos.x, y: pos.y, z: pos.z,
yaw, yaw: Number.isFinite(yawDeg) ? yawDeg : (old?.yaw ?? null),
alive: isAlive, alive: isAlive,
hasBomb: !!hasBomb, hasBomb: !!hasBomb,
hp: Number.isFinite(hp) ? hp : old?.hp ?? null, hp: Number.isFinite(hpNum) ? hpNum : (old?.hp ?? null),
armor: Number.isFinite(armor) ? armor : old?.armor ?? null, armor: Number.isFinite(armorNum) ? armorNum : (old?.armor ?? null),
helmet: helmet ?? old?.helmet ?? null, helmet: helmet ?? (old?.helmet ?? null),
defuse: defuse ?? old?.defuse ?? null, defuse: defuse ?? (old?.defuse ?? null),
}) })
total++ total++
if (isAlive) aliveCount++ 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() clearRoundArtifacts()
} }
@ -589,7 +585,6 @@ export default function LiveRadar() {
const handleGrenades = (g: any) => { const handleGrenades = (g: any) => {
const list = normalizeGrenades(g) const list = normalizeGrenades(g)
// Trails updaten
const seen = new Set<string>() const seen = new Set<string>()
const now = Date.now() const now = Date.now()
for (const it of list) { for (const it of list) {
@ -604,14 +599,10 @@ export default function LiveRadar() {
prev.lastSeen = now prev.lastSeen = now
trailsRef.current.set(it.id, prev) trailsRef.current.set(it.id, prev)
} }
// Trails ausdünnen
for (const [id, tr] of trailsRef.current) { for (const [id, tr] of trailsRef.current) {
if (!seen.has(id) && now - tr.lastSeen > UI.trail.fadeMs) { if (!seen.has(id) && now - tr.lastSeen > UI.trail.fadeMs) trailsRef.current.delete(id)
trailsRef.current.delete(id)
}
} }
// aktuelle Nades übernehmen
const next = new Map<string, Grenade>() const next = new Map<string, Grenade>()
for (const it of list) next.set(it.id, it) for (const it of list) next.set(it.id, it)
grenadesRef.current = next grenadesRef.current = next
@ -619,7 +610,6 @@ export default function LiveRadar() {
scheduleFlush() scheduleFlush()
} }
// erster Flush
useEffect(() => { useEffect(() => {
if (!playersRef.current && !grenadesRef.current) return if (!playersRef.current && !grenadesRef.current) return
scheduleFlush() scheduleFlush()
@ -762,52 +752,45 @@ export default function LiveRadar() {
}, [imgSize, overview]) }, [imgSize, overview])
// ── Bomb "beep" / pulse timing ────────────────────────────── // ── Bomb "beep" / pulse timing ──────────────────────────────
const BOMB_FUSE_MS = 40_000; const BOMB_FUSE_MS = 40_000
const plantedAtRef = useRef<number | null>(null); const plantedAtRef = useRef<number | null>(null)
const beepTimerRef = useRef<number | null>(null); const beepTimerRef = useRef<number | null>(null)
const [beepState, setBeepState] = useState<{ key: number; dur: number } | null>(null); const [beepState, setBeepState] = useState<{ key: number; dur: number } | null>(null)
const getBeepIntervalMs = (remainingMs: number) => { const getBeepIntervalMs = (remainingMs: number) => {
const s = remainingMs / 1000; const s = remainingMs / 1000
if (s > 30) return 1000; if (s > 30) return 1000
if (s > 20) return 900; if (s > 20) return 900
if (s > 10) return 800; if (s > 10) return 800
if (s > 5) return 700; if (s > 5) return 700
return 500; return 500
}; }
const stopBeep = () => { const stopBeep = () => {
if (beepTimerRef.current != null) window.clearTimeout(beepTimerRef.current); if (beepTimerRef.current != null) window.clearTimeout(beepTimerRef.current)
beepTimerRef.current = null; beepTimerRef.current = null
plantedAtRef.current = null; plantedAtRef.current = null
setBeepState(null); setBeepState(null)
}; }
const isBeepActive = !!bomb && (bomb.status === 'planted' || bomb.status === 'defusing'); const isBeepActive = !!bomb && (bomb.status === 'planted' || bomb.status === 'defusing')
useEffect(() => { useEffect(() => {
if (!isBeepActive) { stopBeep(); return; } if (!isBeepActive) { stopBeep(); return }
// nur initial (beim Plant) Startzeit setzen
if (!plantedAtRef.current) { 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 = () => { const tick = () => {
if (!plantedAtRef.current) return; if (!plantedAtRef.current) return
const elapsed = Date.now() - plantedAtRef.current; const elapsed = Date.now() - plantedAtRef.current
const remaining = Math.max(0, BOMB_FUSE_MS - elapsed); const remaining = Math.max(0, BOMB_FUSE_MS - elapsed)
if (remaining <= 0) { stopBeep(); return; } if (remaining <= 0) { stopBeep(); return }
const dur = getBeepIntervalMs(remaining)
const dur = getBeepIntervalMs(remaining); setBeepState(prev => ({ key: (prev?.key ?? 0) + 1, dur }))
setBeepState(prev => ({ key: (prev?.key ?? 0) + 1, dur })); beepTimerRef.current = window.setTimeout(tick, dur)
beepTimerRef.current = window.setTimeout(tick, dur); }
}; tick()
tick(); // erster Ping sofort
} }
}, [isBeepActive]); }, [isBeepActive, bomb])
/* ───────── Status-Badge ───────── */ /* ───────── Status-Badge ───────── */
const WsDot = ({ status, label }: { status: WsStatus, label: string }) => { const WsDot = ({ status, label }: { status: WsStatus, label: string }) => {
@ -830,16 +813,28 @@ export default function LiveRadar() {
) )
} }
/* ───────── Render (Fix A: reines Flex-Layout) ───────── */ /* ───────── Render ───────── */
return ( return (
<div className="h-full min-h-0 w-full flex flex-col overflow-hidden"> <div className="h-full min-h-0 w-full flex flex-col overflow-hidden">
{/* Header */} {/* Header */}
<div className="mb-4 shrink-0 flex items-center justify-between"> <div className="mb-4 shrink-0 flex items-center">
<h2 className="text-xl font-semibold">Live Radar</h2> {/* links */}
<div className="flex items-center gap-4"> <h2 className="text-xl font-semibold flex-1">Live Radar</h2>
<div className="text-sm opacity-80">
{activeMapKey ? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase() : '—'} {/* mitte: Switch zentriert */}
</div> <div className="flex-1 flex justify-center">
<Switch
id="radar-avatar-toggle"
checked={useAvatars}
onChange={setUseAvatars}
labelLeft="Icons"
labelRight="Avatare"
className="mx-auto" // zentriert innerhalb der mittleren Spalte
/>
</div>
{/* rechts: Status */}
<div className="flex-1 flex items-center justify-end gap-4">
<WsDot status={metaWsStatus} label="Meta" /> <WsDot status={metaWsStatus} label="Meta" />
<WsDot status={posWsStatus} label="Pos" /> <WsDot status={posWsStatus} label="Pos" />
</div> </div>
@ -862,42 +857,31 @@ export default function LiveRadar() {
onPlayersAll={(m)=> { handlePlayersAll(m); scheduleFlush() }} onPlayersAll={(m)=> { handlePlayersAll(m); scheduleFlush() }}
onGrenades={(g)=> { handleGrenades(g); 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={() => { onRoundEnd={() => {
// Spieler zurücksetzen
for (const [id, p] of playersRef.current) { for (const [id, p] of playersRef.current) {
playersRef.current.set(id, { ...p, hasBomb: false }) playersRef.current.set(id, { ...p, hasBomb: false })
} }
// Bombenping aus, Marker ggf. als 'defused' stehen lassen
if (bombRef.current?.status === 'planted') { if (bombRef.current?.status === 'planted') {
bombRef.current = { ...bombRef.current, status: 'defused' } bombRef.current = { ...bombRef.current, status: 'defused' }
} }
stopBeep() stopBeep()
// visuelle Artefakte (Trails, Nades, Deaths) aufräumen Bombe NICHT löschen
deathMarkersRef.current = [] deathMarkersRef.current = []
trailsRef.current.clear() trailsRef.current.clear()
grenadesRef.current.clear() grenadesRef.current.clear()
scheduleFlush() scheduleFlush()
}} }}
onBomb={(b)=> { onBomb={(b)=> {
const prev = bombRef.current const prev = bombRef.current
const nb = normalizeBomb(b) const nb = normalizeBomb(b)
if (!nb) return if (!nb) return
const withPos = { const withPos = {
x: Number.isFinite(nb.x) ? nb.x : (prev?.x ?? 0), x: Number.isFinite(nb.x) ? nb.x : (prev?.x ?? 0),
y: Number.isFinite(nb.y) ? nb.y : (prev?.y ?? 0), y: Number.isFinite(nb.y) ? nb.y : (prev?.y ?? 0),
z: Number.isFinite(nb.z) ? nb.z : (prev?.z ?? 0), z: Number.isFinite(nb.z) ? nb.z : (prev?.z ?? 0),
} }
const sameStatus = prev && prev.status === nb.status const sameStatus = prev && prev.status === nb.status
bombRef.current = { bombRef.current = {
...withPos, ...withPos,
@ -908,7 +892,7 @@ export default function LiveRadar() {
}} }}
/> />
{/* Inhalt: 3-Spalten-Layout (T | Radar | CT) */} {/* Inhalt */}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
{!activeMapKey ? ( {!activeMapKey ? (
<div className="h-full grid place-items-center"> <div className="h-full grid place-items-center">
@ -928,13 +912,18 @@ export default function LiveRadar() {
defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive 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 */} {/* Center: Radar */}
<div className="relative min-h-0 rounded-lg overflow-hidden border border-neutral-700 bg-neutral-800"> <div className="relative min-h-0 rounded-lg overflow-hidden border border-neutral-700 bg-neutral-800">
{currentSrc ? ( {currentSrc ? (
<div className="absolute inset-0"> <div className="absolute inset-0">
{/* Bild füllt Container (Letterboxing via object-contain) */}
<img <img
key={currentSrc} key={currentSrc}
src={currentSrc} src={currentSrc}
@ -949,7 +938,14 @@ export default function LiveRadar() {
}} }}
/> />
{/* Overlay skaliert deckungsgleich zum Bild */} {/* Map-Title overlay (zentriert) */}
{activeMapKey && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20 px-3 py-1 rounded bg-black/45 text-white text-xs sm:text-sm font-semibold tracking-wide uppercase pointer-events-none">
{activeMapKey.replace(/^de_/, '').replace(/_/g, ' ')}
</div>
)}
{/* Overlay */}
{imgSize && ( {imgSize && (
<svg <svg
className="absolute inset-0 h-full w-full object-contain pointer-events-none" className="absolute inset-0 h-full w-full object-contain pointer-events-none"
@ -1020,54 +1016,71 @@ export default function LiveRadar() {
{bomb && (() => { {bomb && (() => {
const showBomb = bomb.status === 'planted' || bomb.status === 'defusing' || bomb.status === 'defused' const showBomb = bomb.status === 'planted' || bomb.status === 'defusing' || bomb.status === 'defused'
if (!showBomb) return null if (!showBomb) return null
const P = worldToPx(bomb.x, bomb.y) const P = worldToPx(bomb.x, bomb.y)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
// Mindestgrößen in Pixeln erzwingen const rBase = Math.max(10, unitsToPx(28))
const rBase = Math.max(10, unitsToPx(28)) // Kreis darf klein sein, aber nicht <10px const iconSize = Math.max(24, rBase * 1.8)
const iconSize = Math.max(24, rBase * 1.8) // SVG-Icon mindestens 24px
const isActive = bomb.status === 'planted' || bomb.status === 'defusing' const isActive = bomb.status === 'planted' || bomb.status === 'defusing'
const isDefused = bomb.status === 'defused' 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 ( return (
<g key={`bomb-${bomb.changedAt}`}> <g key={`bomb-${bomb.changedAt}`}>
{isActive && beepState && ( {isActive && beepState && (
<g key={`beep-${beepState.key}`}> <g key={`beep-${beepState.key}`}>
<circle cx={P.x} cy={P.y} r={rBase} fill="none" stroke={isDefused ? '#10b981' : '#ef4444'} strokeWidth={3} <circle
style={{ transformBox: 'fill-box', transformOrigin: 'center', animation: `bombPing ${beepState.dur}ms linear 1` }} /> cx={P.x} cy={P.y} r={rBase}
fill="none"
stroke={isDefused ? '#10b981' : '#ef4444'}
strokeWidth={3}
style={{ transformBox: 'fill-box', transformOrigin: 'center', animation: `bombPing ${beepState.dur}ms linear 1` }}
/>
</g> </g>
)} )}
<image <circle cx={P.x} cy={P.y} r={rBase} fill="#111" opacity="0.15" />
href="/assets/img/icons/ui/bomb_c4.svg"
xlinkHref="/assets/img/icons/ui/bomb_c4.svg" <defs>
x={P.x - iconSize / 2} <mask id={maskId}>
y={P.y - iconSize / 2} <image
href="/assets/img/icons/ui/bomb_c4.svg"
xlinkHref="/assets/img/icons/ui/bomb_c4.svg"
x={P.x - iconSize/2}
y={P.y - iconSize/2}
width={iconSize}
height={iconSize}
preserveAspectRatio="xMidYMid meet"
/>
</mask>
</defs>
<rect
x={P.x - iconSize/2}
y={P.y - iconSize/2}
width={iconSize} width={iconSize}
height={iconSize} height={iconSize}
preserveAspectRatio="xMidYMid meet" fill={iconColor}
mask={`url(#${maskId})`}
/> />
</g> </g>
) )
})()} })()}
{/* Spieler */} {/* Spieler */}
{players {players
.filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false) .filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false)
.map((p) => { .map((p) => {
void avatarVersion
const A = worldToPx(p.x, p.y) const A = worldToPx(p.x, p.y)
const base = Math.min(imgSize.w, imgSize.h) 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 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 let dxp = 0, dyp = 0
if (Number.isFinite(p.yaw as number)) { if (Number.isFinite(p.yaw as number)) {
const yawRad = (Number(p.yaw) * Math.PI) / 180 const yawRad = (Number(p.yaw) * Math.PI) / 180
@ -1079,37 +1092,162 @@ export default function LiveRadar() {
dxp = B.x - A.x dxp = B.x - A.x
dyp = B.y - A.y dyp = B.y - A.y
const cur = Math.hypot(dxp, dyp) || 1 const cur = Math.hypot(dxp, dyp) || 1
const dirLenPx = Math.max(UI.player.dirMinLenPx, rBase * UI.player.dirLenRel)
dxp *= dirLenPx / cur dxp *= dirLenPx / cur
dyp *= 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 ( return (
<g key={p.id}> <g key={p.id}>
<circle {isAvatar ? (
cx={A.x} cy={A.y} r={r} <>
fill={fillColor} stroke={stroke} <defs>
strokeWidth={Math.max(1, r*0.3)} <clipPath id={clipId}>
/> <circle cx={A.x} cy={A.y} r={r} />
{Number.isFinite(p.yaw as number) && ( </clipPath>
<line </defs>
x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp}
stroke={dirColor} strokeWidth={strokeW} strokeLinecap="round" <image
href={String(avatarUrl)}
x={A.x - r}
y={A.y - r}
width={r * 2}
height={r * 2}
clipPath={`url(#${clipId})`}
preserveAspectRatio="xMidYMid slice"
crossOrigin="anonymous"
onError={(e) => {
const img = e.currentTarget as SVGImageElement
if (!img.getAttribute('data-fallback')) {
img.setAttribute('data-fallback', '1')
img.setAttribute('href', DEFAULT_AVATAR)
}
}}
/>
{/* Team-/Bomben-Ring */}
<circle
cx={A.x} cy={A.y} r={r}
fill="none"
stroke={ringColor}
strokeWidth={Math.max(1.2, r * UI.player.avatarRingWidthRel)}
/>
{p.id === hoveredPlayerId && (
<g>
{/* dezenter statischer Ring */}
<circle
cx={A.x} cy={A.y} r={r + Math.max(4, r * 0.35)}
fill="none"
stroke="#ffffff"
strokeOpacity={0.9}
strokeWidth={1.5}
/>
{/* pulsierender Ping */}
<circle
cx={A.x} cy={A.y} r={r + Math.max(6, r * 0.5)}
fill="none"
stroke="#ffffff"
strokeWidth={2}
style={{
transformBox: 'fill-box',
transformOrigin: 'center',
animation: 'radarPing 1200ms ease-out infinite'
}}
/>
</g>
)}
{/* Zusatz: kleiner roter Außenring, wenn Bombe getragen wird */}
{p.hasBomb && (
<circle
cx={A.x} cy={A.y}
r={r + Math.max(1, r * 0.18)}
fill="none"
stroke={UI.player.bombStroke}
strokeWidth={Math.max(1.2, r * 0.15)}
/>
)}
</>
) : (
// Icons (wenn Avatare aus)
<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) && (
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 (
<path
d={`M ${x1} ${y1} A ${r} ${r} 0 0 1 ${x2} ${y2}`}
fill="none"
stroke={dirColor} // gut sichtbar auf Teamfarbe
strokeWidth={ringW}
strokeLinecap="round"
/>
)
})()
) : (
// Fallback: klassische Linie vom Mittelpunkt (wenn Avatare AUS)
<line
x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp}
stroke={dirColor}
strokeWidth={strokeW}
strokeLinecap="round"
/>
)
)}
</g> </g>
) )
})} })
}
{/* Death-Marker (SVG statt "X") */} {/* Death-Marker */}
{deathMarkers.map(dm => { {deathMarkers.map(dm => {
const P = worldToPx(dm.x, dm.y) const P = worldToPx(dm.x, dm.y)
const size = Math.max(10, UI.death.sizePx) // z.B. 1620 für bessere Sichtbarkeit const size = Math.max(10, UI.death.sizePx)
const key = dm.sid ? `death-${dm.sid}` : `death-${dm.id}`; // Fallback, falls mal keine SID vorliegt const key = dm.sid ? `death-${dm.sid}` : `death-${dm.id}`
return ( return (
<g key={key}> <g key={key}>
<image <image
href="/assets/img/icons/ui/map_death.svg" href="/assets/img/icons/ui/map_death.svg"
xlinkHref="/assets/img/icons/ui/map_death.svg" // Safari/ältere Renderer xlinkHref="/assets/img/icons/ui/map_death.svg"
x={P.x - size / 2} x={P.x - size / 2}
y={P.y - size / 2} y={P.y - size / 2}
width={size} width={size}
@ -1140,14 +1278,25 @@ export default function LiveRadar() {
defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive 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}
/> />
</div> </div>
)} )}
</div> </div>
<style jsx global>{` <style jsx global>{`
@keyframes bombPing { @keyframes bombPing {
from { transform: scale(1); opacity: 0.7; } from { transform: scale(1); opacity: 0.7; }
to { transform: scale(8); opacity: 0; } to { transform: scale(8); opacity: 0; }
}
@keyframes radarPing {
from { transform: scale(1); opacity: 0.9; }
to { transform: scale(2.6); opacity: 0; }
} }
`}</style> `}</style>
</div> </div>

View File

@ -1,9 +1,11 @@
// TeamSidebar.tsx
'use client' 'use client'
import React from 'react' import React, { useEffect } from 'react'
import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore'
export type Team = 'T' | 'CT' export type Team = 'T' | 'CT'
export type SidebarPlayer = { export type SidebarPlayer = {
id: string id: string // <- SteamID
name?: string | null name?: string | null
hp?: number | null hp?: number | null
armor?: number | null armor?: number | null
@ -17,18 +19,32 @@ export default function TeamSidebar({
team, team,
players, players,
align = 'left', align = 'left',
onHoverPlayer,
}: { }: {
team: Team team: Team
players: SidebarPlayer[] players: SidebarPlayer[]
align?: 'left' | 'right' 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 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 aliveCount = players.filter(p => p.alive !== false && (p.hp ?? 1) > 0).length
const sorted = [...players].sort((a, b) => { const sorted = [...players].sort((a, b) => {
// alive first, then hp desc, then name
const al = (b.alive ? 1 : 0) - (a.alive ? 1 : 0) const al = (b.alive ? 1 : 0) - (a.alive ? 1 : 0)
if (al !== 0) return al if (al !== 0) return al
const hp = (b.hp ?? -1) - (a.hp ?? -1) const hp = (b.hp ?? -1) - (a.hp ?? -1)
@ -43,31 +59,69 @@ export default function TeamSidebar({
<span className="tabular-nums">{aliveCount}/{players.length}</span> <span className="tabular-nums">{aliveCount}/{players.length}</span>
</div> </div>
<div className="mt-2 flex-1 overflow-auto space-y-2 pr-1"> <div className="mt-2 flex-1 overflow-auto space-y-3 pr-1">
{sorted.map(p => { {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 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 ( return (
<div key={p.id} className={`rounded-md px-2 py-1.5 bg-neutral-800/40 ${dead ? 'opacity-50' : ''}`}> <div
<div className="flex items-center gap-2"> key={p.id}
<span className={`inline-block w-1.5 h-1.5 rounded-full ${team === 'CT' ? 'bg-blue-400' : 'bg-amber-400'}`} /> onMouseEnter={() => onHoverPlayer?.(p.id)}
<div className="truncate flex-1">{p.name || p.id}</div> onMouseLeave={() => onHoverPlayer?.(null)}
{/* kleine Status-Icons */} //className={`rounded-md px-2 py-2 bg-neutral-800/40 ${dead ? 'opacity-60' : ''}`}
{p.hasBomb && team === 'T' && <span title="Bomb" className="text-red-400">💣</span>} className={`rounded-md px-2 py-2 transition cursor-pointer
{p.helmet && <span title="Helmet" className="opacity-80">🪖</span>} bg-neutral-800/40 hover:bg-neutral-700/40
{p.defuse && team === 'CT' && <span title="Defuse Kit" className="opacity-80">🗝</span>} hover:ring-2 hover:ring-white/20
<div className={`text-xs tabular-nums ${align === 'right' ? 'text-right' : ''}`}>{hp}</div> ${dead ? 'opacity-60' : ''}`}
</div> >
<div className={`flex ${rowDir} items-center gap-3`}>
{/* Avatar groß + Team-Ring */}
<img
src={avatarUrl}
alt={p.name || p.id}
className={`w-12 h-12 rounded-full object-cover border border-white/10 ring-2 ${ringColor} bg-neutral-900`}
width={48}
height={48}
loading="lazy"
/>
{/* HP-Bar */} {/* Stack: Name + Icons, darunter die größeren Balken */}
<div className="mt-1 h-2 rounded bg-neutral-700/60 overflow-hidden"> <div className={`flex-1 min-w-0 flex flex-col ${stackAlg}`}>
<div className="h-full bg-green-500" style={{ width: `${hp}%` }} /> {/* Kopfzeile */}
</div> <div className={`flex ${isRight ? 'flex-row-reverse' : ''} items-center gap-2 w-full`}>
{/* Armor-Bar */} <span className="truncate font-medium">{p.name || p.id}</span>
<div className="mt-1 h-1 rounded bg-neutral-700/60 overflow-hidden"> {p.hasBomb && team === 'T' && <span title="Bomb" className="text-red-400">💣</span>}
<div className={`h-full ${barArmor}`} style={{ width: `${armor}%` }} /> {p.helmet && <span title="Helmet" className="opacity-80">🪖</span>}
{p.defuse && team === 'CT' && <span title="Defuse Kit" className="opacity-80">🗝</span>}
<span className={`${isRight ? 'mr-auto' : 'ml-auto'} text-xs tabular-nums`}>{hp}</span>
</div>
{/* Größere Balken */}
<div className="mt-1 w-full">
{/* HP */}
<div className="h-2.5 rounded bg-neutral-700/60 overflow-hidden">
<div className="h-full bg-green-500" style={{ width: `${hp}%` }} />
</div>
{/* Armor */}
<div className="mt-1 h-1.5 rounded bg-neutral-700/60 overflow-hidden">
<div className={`h-full ${barArmor}`} style={{ width: `${armor}%` }} />
</div>
</div>
</div>
</div> </div>
</div> </div>
) )

View File

@ -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<string, AvatarUser | NotFound>
loading: Set<string>
version: number
upsert: (u: AvatarUser | NotFound) => void
ensureLoaded: (steamIds: string[]) => Promise<void>
}
const isValidSteamId = (s: string) => /^\d{17}$/.test(s)
export const useAvatarDirectoryStore = create<Store>((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<void> => {
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<AvatarUser>
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 }
})
}
},
}))