update
This commit is contained in:
parent
d9911012b7
commit
3c68c3ad2c
@ -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<string | null>(null)
|
||||
|
||||
// Spieler
|
||||
// Spieler-live
|
||||
const playersRef = useRef<Map<string, PlayerState>>(new Map())
|
||||
const [players, setPlayers] = useState<PlayerState[]>([])
|
||||
const [hoveredPlayerId, setHoveredPlayerId] = useState<string | null>(null)
|
||||
|
||||
// Deaths
|
||||
const deathSeqRef = useRef(0);
|
||||
const deathSeenRef = useRef<Set<string>>(new Set());
|
||||
const deathSeqRef = useRef(0)
|
||||
const deathSeenRef = useRef<Set<string>>(new Set())
|
||||
|
||||
// Grenaden + Trails
|
||||
const grenadesRef = useRef<Map<string, Grenade>>(new Map())
|
||||
@ -210,6 +215,21 @@ export default function LiveRadar() {
|
||||
const bombRef = useRef<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
|
||||
const flushTimer = useRef<number | null>(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<string>()
|
||||
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<string, Grenade>()
|
||||
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<number | null>(null);
|
||||
const beepTimerRef = useRef<number | null>(null);
|
||||
const [beepState, setBeepState] = useState<{ key: number; dur: number } | null>(null);
|
||||
const BOMB_FUSE_MS = 40_000
|
||||
const plantedAtRef = useRef<number | null>(null)
|
||||
const beepTimerRef = useRef<number | null>(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 (
|
||||
<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>
|
||||
<div className="mb-4 shrink-0 flex items-center">
|
||||
{/* links */}
|
||||
<h2 className="text-xl font-semibold flex-1">Live Radar</h2>
|
||||
|
||||
{/* mitte: Switch zentriert */}
|
||||
<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={posWsStatus} label="Pos" />
|
||||
</div>
|
||||
@ -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 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{!activeMapKey ? (
|
||||
<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
|
||||
}))
|
||||
}
|
||||
// Optional, falls deine TeamSidebar Avatare nutzt:
|
||||
// @ts-ignore
|
||||
showAvatars={useAvatars}
|
||||
// @ts-ignore
|
||||
avatarsById={avatarById}
|
||||
onHoverPlayer={setHoveredPlayerId}
|
||||
/>
|
||||
|
||||
{/* 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}
|
||||
@ -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 && (
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full object-contain pointer-events-none"
|
||||
@ -1020,54 +1016,71 @@ export default function LiveRadar() {
|
||||
{bomb && (() => {
|
||||
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 (
|
||||
<g key={`bomb-${bomb.changedAt}`}>
|
||||
{isActive && beepState && (
|
||||
<g key={`beep-${beepState.key}`}>
|
||||
<circle 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` }} />
|
||||
<circle
|
||||
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>
|
||||
)}
|
||||
|
||||
<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}
|
||||
<circle cx={P.x} cy={P.y} r={rBase} fill="#111" opacity="0.15" />
|
||||
|
||||
<defs>
|
||||
<mask id={maskId}>
|
||||
<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}
|
||||
height={iconSize}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
fill={iconColor}
|
||||
mask={`url(#${maskId})`}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})()}
|
||||
|
||||
|
||||
{/* 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 (
|
||||
<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"
|
||||
{isAvatar ? (
|
||||
<>
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<circle cx={A.x} cy={A.y} r={r} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
})
|
||||
}
|
||||
|
||||
{/* 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 (
|
||||
<g key={key}>
|
||||
<image
|
||||
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}
|
||||
y={P.y - size / 2}
|
||||
width={size}
|
||||
@ -1140,14 +1278,25 @@ 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
@keyframes bombPing {
|
||||
from { transform: scale(1); opacity: 0.7; }
|
||||
to { transform: scale(8); opacity: 0; }
|
||||
from { transform: scale(1); opacity: 0.7; }
|
||||
to { transform: scale(8); opacity: 0; }
|
||||
}
|
||||
@keyframes radarPing {
|
||||
from { transform: scale(1); opacity: 0.9; }
|
||||
to { transform: scale(2.6); opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
@ -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({
|
||||
<span className="tabular-nums">{aliveCount}/{players.length}</span>
|
||||
</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 => {
|
||||
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 (
|
||||
<div key={p.id} className={`rounded-md px-2 py-1.5 bg-neutral-800/40 ${dead ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full ${team === 'CT' ? 'bg-blue-400' : 'bg-amber-400'}`} />
|
||||
<div className="truncate flex-1">{p.name || p.id}</div>
|
||||
{/* kleine Status-Icons */}
|
||||
{p.hasBomb && team === 'T' && <span title="Bomb" className="text-red-400">💣</span>}
|
||||
{p.helmet && <span title="Helmet" className="opacity-80">🪖</span>}
|
||||
{p.defuse && team === 'CT' && <span title="Defuse Kit" className="opacity-80">🗝️</span>}
|
||||
<div className={`text-xs tabular-nums ${align === 'right' ? 'text-right' : ''}`}>{hp}</div>
|
||||
</div>
|
||||
<div
|
||||
key={p.id}
|
||||
onMouseEnter={() => 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' : ''}`}
|
||||
>
|
||||
<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 */}
|
||||
<div className="mt-1 h-2 rounded bg-neutral-700/60 overflow-hidden">
|
||||
<div className="h-full bg-green-500" style={{ width: `${hp}%` }} />
|
||||
</div>
|
||||
{/* Armor-Bar */}
|
||||
<div className="mt-1 h-1 rounded bg-neutral-700/60 overflow-hidden">
|
||||
<div className={`h-full ${barArmor}`} style={{ width: `${armor}%` }} />
|
||||
{/* Stack: Name + Icons, darunter die größeren Balken */}
|
||||
<div className={`flex-1 min-w-0 flex flex-col ${stackAlg}`}>
|
||||
{/* Kopfzeile */}
|
||||
<div className={`flex ${isRight ? 'flex-row-reverse' : ''} items-center gap-2 w-full`}>
|
||||
<span className="truncate font-medium">{p.name || p.id}</span>
|
||||
{p.hasBomb && team === 'T' && <span title="Bomb" className="text-red-400">💣</span>}
|
||||
{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>
|
||||
)
|
||||
|
||||
99
src/app/lib/useAvatarDirectoryStore.ts
Normal file
99
src/app/lib/useAvatarDirectoryStore.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
Loading…
x
Reference in New Issue
Block a user