- Verbunden mit {serverLabel ?? 'CS2-Server'}
+
+
+ {serverLabel ?? 'CS2-Server'}
+
Map: {prettyMap}
@@ -173,7 +175,11 @@ export default function TelemetryBanner({
>
) : (
<>
-
Verbindung getrennt
+
+
+ Verbindung getrennt
+
+
Map: {prettyMap}
Phase: {prettyPhase}
@@ -235,25 +241,27 @@ export default function TelemetryBanner({
/>
)}
- {/* „X“ Disconnect ganz rechts */}
-
+
+
Verlassen
+
+ )}
diff --git a/src/app/components/TelemetrySocket.tsx b/src/app/components/TelemetrySocket.tsx
index f2bbc71..56969a2 100644
--- a/src/app/components/TelemetrySocket.tsx
+++ b/src/app/components/TelemetrySocket.tsx
@@ -37,6 +37,10 @@ function parseServerLabel(uri: string | null | undefined): string {
}
}
+function quoteArg(s: string) {
+ return `"${String(s ?? '').replace(/"/g, '\\"')}"`
+}
+
function labelForMap(key?: string | null): string {
if (!key) return '—'
const k = String(key).toLowerCase()
@@ -62,6 +66,11 @@ export default function TelemetrySocket() {
const { data: session } = useSession()
const mySteamId = (session?.user as any)?.steamId ?? null
+ const myName =
+ (session?.user as any)?.name ??
+ (session?.user as any)?.steamName ??
+ (session?.user as any)?.displayName ??
+ null
// overlay control
const hideOverlay = useReadyOverlayStore((s) => s.hide)
@@ -248,7 +257,7 @@ export default function TelemetrySocket() {
try { window.location.href = connectUri } catch {}
}
- const handleDisconnect = () => {
+ const handleDisconnect = async () => {
// Auto-Reconnect stoppen
aliveRef.current = false;
if (retryRef.current) {
@@ -256,16 +265,33 @@ export default function TelemetrySocket() {
retryRef.current = null;
}
- // WebSocket sauber schließen
+ // WebSocket zu
try { wsRef.current?.close(1000, 'user requested disconnect') } catch {}
wsRef.current = null;
- // Lokalen Zustand zurücksetzen (wir bleiben im "disconnected"-Banner)
+ // ❗ NICHT: setPhase('unknown'), setMapKeyForUi(null), setServerName(null)
+ // Nur "Online"-Set leeren, damit variant = 'disconnected'
setTelemetrySet(new Set());
- setServerName(null);
- setMapKeyForUi(null);
- setPhase('unknown' as any);
- setScore({ a: null, b: null });
+ // Score darf bleiben; falls du willst, kannst du ihn optional leeren:
+ // setScore({ a: null, b: null });
+
+ // Kick an Server schicken
+ try {
+ const who = myName || mySteamId;
+ if (who) {
+ const cmd = `kick ${quoteArg(String(who))}`;
+ await fetch('/api/cs2/server/send-command', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ cache: 'no-store',
+ body: JSON.stringify({ command: cmd }),
+ });
+ }
+ } catch (err) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn('[TelemetrySocket] kick command failed:', err);
+ }
+ }
};
const variant: 'connected' | 'disconnected' = iAmOnline ? 'connected' : 'disconnected'
diff --git a/src/app/radar/GameSocket.tsx b/src/app/components/radar/GameSocket.tsx
similarity index 52%
rename from src/app/radar/GameSocket.tsx
rename to src/app/components/radar/GameSocket.tsx
index 961cf22..596f8f1 100644
--- a/src/app/radar/GameSocket.tsx
+++ b/src/app/components/radar/GameSocket.tsx
@@ -15,6 +15,80 @@ type GameSocketProps = {
onBomb?: (b:any) => void
}
+// HINZUFÜGEN: oben im Modul – kleine Helfer
+function pickVec3Loose(src: any) {
+ // akzeptiert {x,y,z}, [x,y,z], "x, y, z"
+ if (!src) return null
+ if (Array.isArray(src)) {
+ const [x, y, z] = src
+ const nx = Number(x), ny = Number(y), nz = Number(z)
+ if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 }
+ return null
+ }
+ if (typeof src === 'string') {
+ const parts = src.split(',').map(s => Number(s.trim()))
+ if (parts.length >= 2 && parts.slice(0,2).every(Number.isFinite)) {
+ return { x: parts[0], y: parts[1], z: Number.isFinite(parts[2]) ? parts[2] : 0 }
+ }
+ return null
+ }
+ const nx = Number(src?.x), ny = Number(src?.y), nz = Number(src?.z)
+ if ([nx, ny].every(Number.isFinite)) return { x: nx, y: ny, z: Number.isFinite(nz) ? nz : 0 }
+ return null
+}
+
+function extractBombPayload(msg: any): any | null {
+ // 1) Wenn msg.bomb / msg.c4 schon da ist → ggf. Position aus bekannten Feldern ergänzen
+ const base = msg?.bomb ?? msg?.c4 ?? null
+
+ // mögliche Felder, wo Positionsinfos oft landen
+ const posCandidates = [
+ base?.pos, base?.position, base?.location, base?.coordinates, base?.origin,
+ msg?.bomb_pos, msg?.bomb_position, msg?.bombLocation, msg?.bomblocation,
+ msg?.pos, msg?.position, msg?.location, msg?.coordinates, msg?.origin,
+ msg?.world?.bomb, msg?.objectives?.bomb
+ ]
+
+ let P = null
+ for (const p of posCandidates) { P = pickVec3Loose(p); if (P) break }
+
+ // Status aus explizitem Feld oder vom Event-Type ableiten
+ const t = String(msg?.type ?? '').toLowerCase()
+ let status: 'carried'|'dropped'|'planted'|'defusing'|'defused'|'unknown' = 'unknown'
+ const s = String(base?.status ?? base?.state ?? '').toLowerCase()
+ if (s.includes('plant')) status = 'planted'
+ 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('defus') && s.includes('ed')) status = 'defused'
+
+ if (t === 'bomb_planted') status = 'planted'
+ else if (t === 'bomb_dropped') status = 'dropped'
+ else if (t === 'bomb_pickup') status = 'carried'
+ else if (t === 'bomb_begindefuse') status = 'defusing'
+ else if (t === 'bomb_abortdefuse') status = 'planted'
+ else if (t === 'bomb_defused') status = 'defused'
+
+ // Wir wollen nur liefern, wenn NICHT getragen
+ const notCarried = status !== 'carried'
+
+ if (!base && !P && !t.startsWith('bomb_')) return null
+ if (!notCarried && !t.startsWith('bomb_')) return null
+
+ const payload = {
+ // Lass LiveRadar.normalizeBomb entscheiden – wir geben „bomb“ aus
+ bomb: {
+ ...(base || {}),
+ ...(P ? { x: P.x, y: P.y, z: P.z } : {}),
+ status
+ },
+ // original message für evtl. weitere Felder
+ type: msg?.type
+ }
+ return payload
+}
+
+
export default function GameSocket(props: GameSocketProps) {
const { url, onStatus, onMap, onPlayerUpdate, onPlayersAll, onGrenades, onRoundStart, onRoundEnd, onBomb } = props
const wsRef = useRef
(null)
@@ -35,31 +109,43 @@ export default function GameSocket(props: GameSocketProps) {
const g = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles;
if (g) onGrenades?.(g);
+ // 1) Bisher: direkt durchreichen
if (msg.bomb) onBomb?.(msg.bomb);
+
+ // 2) NEU: Falls keine msg.bomb vorhanden, aber Position/Status auffindbar → synthetische Bomb-Payload senden
+ if (!msg.bomb) {
+ const synth = extractBombPayload(msg);
+ if (synth) onBomb?.(synth);
+ }
+
onPlayersAll?.(msg);
return;
}
// --- non-tick messages (hello, map, bomb_* events, etc.) ---
-
- // Map kann als String ODER als Objekt kommen
if (typeof msg.map === 'string') {
onMap?.(msg.map.toLowerCase());
} else if (msg.map && typeof msg.map.name === 'string') {
onMap?.(msg.map.name.toLowerCase());
}
- // komplette Snapshot-Payload
if (msg.allplayers) onPlayersAll?.(msg);
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg);
- // Granaten über alle bekannten Keys (einmalig) weiterreichen
const g2 = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles;
if (g2) onGrenades?.(g2);
// Bombe: generische Events + direkte bomb/c4-Payload
const t = String(msg.type || '').toLowerCase();
- if (msg.bomb || msg.c4 || t.startsWith('bomb_')) onBomb?.(msg);
+
+ if (msg.bomb || msg.c4) {
+ onBomb?.(msg); // unverändert weiterreichen
+ } else if (t.startsWith('bomb_')) {
+ // NEU: Event ohne bomb-Objekt → mit Position/Status anreichern
+ const enriched = extractBombPayload(msg);
+ if (enriched) onBomb?.(enriched);
+ else onBomb?.(msg); // Fallback: Event trotzdem melden
+ }
};
diff --git a/src/app/components/radar/LiveRadar.tsx b/src/app/components/radar/LiveRadar.tsx
new file mode 100644
index 0000000..a053973
--- /dev/null
+++ b/src/app/components/radar/LiveRadar.tsx
@@ -0,0 +1,260 @@
+'use client'
+
+import { useEffect, useMemo, useState } from 'react';
+import { useSession } from 'next-auth/react';
+import GameSocket from './GameSocket';
+import TeamSidebar from './TeamSidebar';
+import StaticEffects from './StaticEffects';
+import RadarHeader from './RadarHeader';
+import RadarCanvas from './RadarCanvas';
+
+import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore';
+import { useTelemetryStore } from '@/app/lib/useTelemetryStore';
+
+import { useBombBeep } from './hooks/useBombBeep';
+import { useOverview } from './hooks/useOverview';
+import { useRadarState } from './hooks/useRadarState';
+
+import { Grenade } from './lib/types';
+import { UI } from './lib/ui';
+import { teamOfGrenade } from './lib/grenades';
+import { BombState } from './lib/types';
+
+const teamIdT = undefined as string | undefined;
+const teamIdCT = undefined as string | undefined;
+
+function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
+ const h = (host ?? '').trim() || '127.0.0.1';
+ const p = (port ?? '').trim() || '8081';
+ const pa = (path ?? '').trim() || '/telemetry';
+ const sch = (scheme ?? '').toLowerCase();
+ const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
+ const useWss = sch === 'wss' || (sch !== 'ws' && (p === '443' || pageHttps));
+ const proto = useWss ? 'wss' : 'ws';
+ const portPart = (p === '80' || p === '443') ? '' : `:${p}`;
+ return `${proto}://${h}${portPart}${pa}`;
+}
+const gameUrl = makeWsUrl(
+ process.env.NEXT_PUBLIC_CS2_GAME_WS_HOST,
+ process.env.NEXT_PUBLIC_CS2_GAME_WS_PORT,
+ process.env.NEXT_PUBLIC_CS2_GAME_WS_PATH,
+ process.env.NEXT_PUBLIC_CS2_GAME_WS_SCHEME
+);
+
+export default function LiveRadar() {
+ // Session / User
+ const { data: session, status } = useSession();
+ const isAuthed = status === 'authenticated';
+ const mySteamId: string | null = useMemo(() => {
+ const u: any = session?.user;
+ const cands = [u?.steamId, u?.steamid, u?.steam_id, u?.id];
+ const first = cands.find(Boolean);
+ return first ? String(first) : null;
+ }, [session?.user]);
+
+ // Avatar store
+ const ensureTeamsLoaded = useAvatarDirectoryStore(s => s.ensureTeamsLoaded);
+ const avatarVersion = useAvatarDirectoryStore(s => s.version);
+ const avatarById = useAvatarDirectoryStore(s => s.byId);
+
+ // Radar state (alles zentrale Zeug)
+ const {
+ radarWsStatus, setGameWsStatus,
+ activeMapKey, setActiveMapKey,
+ players, playersRef, hoveredPlayerId, setHoveredPlayerId,
+ grenades, trails, deathMarkers,
+ bomb, roundPhase, roundEndsAtRef, bombEndsAtRef, defuseRef,
+ score, myTeam,
+ upsertPlayer, handlePlayersAll, handleGrenades, handleBomb,
+ clearRoundArtifacts, scheduleFlush,
+ } = useRadarState(mySteamId);
+
+ // Avatare toggle (persist)
+ 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]);
+
+ // Teams preload
+ useEffect(() => {
+ const ids = [teamIdT, teamIdCT].filter(Boolean) as string[];
+ if (ids.length) ensureTeamsLoaded(ids);
+ }, [ensureTeamsLoaded]);
+
+ // Map-Key aus Telemetry übernehmen
+ const mapKeyFromTelemetry = useTelemetryStore(s => s.mapKey);
+ useEffect(() => { if (mapKeyFromTelemetry) setActiveMapKey(mapKeyFromTelemetry); }, [mapKeyFromTelemetry, setActiveMapKey]);
+
+ // overview + mapping
+ const { overview, imgSize, setImgSize, currentSrc, srcIdx, setSrcIdx, worldToPx, unitsToPx } =
+ useOverview(activeMapKey, players.map(p=>({x:p.x,y:p.y})));
+ void overview; void srcIdx; // kept if you want to expose choice UI later
+
+ // Bomb beep state
+ const { beepState } = useBombBeep(bomb);
+ const bombSecLeft = bombEndsAtRef.current == null ? null : Math.max(0, Math.ceil((bombEndsAtRef.current - Date.now())/1000));
+ const bombFinal10 = bombSecLeft != null && bombSecLeft <= 10;
+
+ // helper: grenade filter by team
+ const teamOfPlayer = (sid?: string | null): 'T' | 'CT' | string | null => {
+ if (!sid) return null;
+ return playersRef.current.get(sid)?.team ?? null;
+ };
+ const shouldShowGrenade = (g: Grenade): boolean => {
+ if (myTeam !== 'T' && myTeam !== 'CT') return true;
+ const gt = teamOfGrenade(g, teamOfPlayer);
+ return gt === myTeam;
+ };
+
+ if (!isAuthed) {
+ return (
+
+
+
Live Radar
+
Bitte einloggen, um das Live-Radar zu sehen.
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Unsichtbare WS-Clients */}
+
setActiveMapKey(String(k).toLowerCase())}
+ onPlayerUpdate={(p)=> { upsertPlayer(p); scheduleFlush() }}
+ onPlayersAll={(m)=> { handlePlayersAll(m); scheduleFlush() }}
+ onGrenades={(g)=> { handleGrenades(g); scheduleFlush() }}
+ onRoundStart={() => { clearRoundArtifacts(true) }}
+ onRoundEnd={() => {
+ for (const [id, p] of playersRef.current) playersRef.current.set(id, { ...p, hasBomb: false });
+ if (bomb?.status === 'planted') { /* visual cleanup handled in state */ }
+ clearRoundArtifacts(true);
+ }}
+ onBomb={handleBomb((raw:any) => {
+ // lokal: normalizeBomb (aus alter Datei) – du kannst es ebenfalls auslagern, falls gewünscht
+ const pickVec3 = (src:any) => {
+ const p = src?.pos ?? src?.position ?? src?.location ?? src?.coordinates;
+ if (Array.isArray(p)) return { x: +p[0]||0, y: +p[1]||0, z: +p[2]||0 };
+ if (typeof p === 'string') {
+ const [x, y, z] = p.split(',').map(s=>Number(s.trim()));
+ return { x:x||0, y:y||0, z:z||0 };
+ }
+ return { x: +src?.x||0, y: +src?.y||0, z: +src?.z||0 };
+ };
+ if (!raw) return null;
+ const payload = raw.bomb ?? raw.c4 ?? raw;
+ const pos = pickVec3(payload);
+ const t = String(raw?.type ?? '').toLowerCase();
+ if (t === 'bomb_beginplant' || t === 'bomb_abortplant') return null;
+
+ let status: BombState['status'] = 'unknown';
+ const s = String(payload?.status ?? payload?.state ?? '').toLowerCase();
+ if (s.includes('planted')) status = 'planted';
+ else if (s.includes('drop')) status = 'dropped';
+ else if (s.includes('carry')) status = 'carried';
+ else if (s.includes('defus')) status = 'defusing';
+ if (payload?.planted) status = 'planted';
+ if (payload?.dropped) status = 'dropped';
+ if (payload?.carried) status = 'carried';
+ if (payload?.defusing) status = 'defusing';
+ if (payload?.defused) status = 'defused';
+
+ 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';
+
+ 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() };
+ })}
+ />
+
+ {/* Inhalt */}
+
+ {/* Left: T */}
+ {myTeam !== 'CT' && (
+ p.team === 'T' && (!myTeam || p.team === myTeam))
+ .map(p => ({
+ id: p.id, name: p.name, hp: p.hp, armor: p.armor, helmet: p.helmet,
+ defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive,
+ activeWeapon: p.activeWeapon || null,
+ weapons: p.weapons || null,
+ grenades: p.nades || null,
+ }))
+ }
+ // @ts-ignore
+ showAvatars={useAvatars}
+ // @ts-ignore
+ avatarsById={avatarById}
+ onHoverPlayer={setHoveredPlayerId}
+ />
+ )}
+
+ {/* Center: Radar */}
+ setImgSize({ w: img.naturalWidth, h: img.naturalHeight })}
+ onImgError={() => {}}
+ imgSize={imgSize}
+ worldToPx={worldToPx}
+ unitsToPx={unitsToPx}
+ players={players}
+ grenades={grenades}
+ trails={trails}
+ deathMarkers={deathMarkers}
+ useAvatars={useAvatars}
+ avatarById={avatarById}
+ hoveredPlayerId={hoveredPlayerId}
+ setHoveredPlayerId={setHoveredPlayerId}
+ myTeam={myTeam}
+ beepState={beepState}
+ bombFinal10={bombFinal10}
+ bomb={bomb}
+ shouldShowGrenade={shouldShowGrenade}
+ />
+
+ {/* Right: CT */}
+ {myTeam !== 'T' && (
+ p.team === 'CT' && (!myTeam || p.team === myTeam))
+ .map(p => ({
+ id: p.id, name: p.name, hp: p.hp, armor: p.armor, helmet: p.helmet,
+ defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive,
+ activeWeapon: p.activeWeapon || null,
+ weapons: p.weapons || null,
+ grenades: p.nades || null,
+ }))
+ }
+ // @ts-ignore
+ showAvatars={useAvatars}
+ // @ts-ignore
+ avatarsById={avatarById}
+ onHoverPlayer={setHoveredPlayerId}
+ />
+ )}
+
+
+ );
+}
diff --git a/src/app/components/radar/RadarCanvas.tsx b/src/app/components/radar/RadarCanvas.tsx
new file mode 100644
index 0000000..5ee168c
--- /dev/null
+++ b/src/app/components/radar/RadarCanvas.tsx
@@ -0,0 +1,282 @@
+'use client'
+import StaticEffects from './StaticEffects';
+import { BOT_ICON, DEFAULT_AVATAR, EQUIP_ICON, UI } from './lib/ui';
+import { contrastStroke } from './lib/helpers';
+import { Grenade, Mapper, PlayerState, Trail, DeathMarker, BombState } from './lib/types';
+
+export default function RadarCanvas({
+ activeMapKey,
+ currentSrc, onImgLoad, onImgError,
+ imgSize,
+ worldToPx, unitsToPx,
+ players, grenades, trails, deathMarkers,
+ useAvatars, avatarById, hoveredPlayerId, setHoveredPlayerId,
+ myTeam,
+ beepState, bombFinal10,
+ bomb,
+ shouldShowGrenade,
+}: {
+ activeMapKey: string | null;
+ currentSrc?: string;
+ onImgLoad: (img: HTMLImageElement)=>void;
+ onImgError: ()=>void;
+ imgSize: {w:number;h:number} | null;
+ worldToPx: Mapper;
+ unitsToPx: (u:number)=>number;
+ players: PlayerState[];
+ grenades: Grenade[];
+ trails: Trail[];
+ deathMarkers: DeathMarker[];
+ useAvatars: boolean;
+ avatarById: Record;
+ hoveredPlayerId: string | null;
+ setHoveredPlayerId: (id: string|null)=>void;
+ myTeam: 'T'|'CT'|string|null;
+ beepState: {key:number;dur:number}|null;
+ bombFinal10: boolean;
+ bomb: BombState | null;
+ shouldShowGrenade: (g:Grenade)=>boolean;
+}) {
+ if (!activeMapKey) {
+ return (
+
+
+ Keine Map erkannt.
+
+
+ );
+ }
+
+ const raw = activeMapKey.replace(/^de_/, '').replace(/[_-]+/g, ' ').trim()
+ // Leerzeichen zwischen Buchstabe↔Zahl einfügen (z.B. "dust2" -> "dust 2")
+ const spaced = raw.replace(/(\D)(\d)/g, '$1 $2')
+ // Jedes Wort kapitalisieren
+ const pretty = spaced.replace(/\b\w/g, c => c.toUpperCase())
+
+ return (
+
+ {/* Topbar */}
+
+
+ {currentSrc ? (
+
+

onImgLoad(e.currentTarget)}
+ onError={onImgError}
+ />
+
+ {imgSize && (
+
+ )}
+
+ ) : (
+
Keine Radar-Grafik gefunden.
+ )}
+
+ {/* Global styles kept here for animations */}
+
+
+ );
+}
diff --git a/src/app/components/radar/RadarHeader.tsx b/src/app/components/radar/RadarHeader.tsx
new file mode 100644
index 0000000..5f3902f
--- /dev/null
+++ b/src/app/components/radar/RadarHeader.tsx
@@ -0,0 +1,31 @@
+'use client'
+import StatusDot from '../StatusDot';
+import Switch from '../Switch';
+import { WsStatus } from './lib/types';
+
+export default function RadarHeader({
+ useAvatars, setUseAvatars, radarWsStatus,
+}: {
+ useAvatars: boolean;
+ setUseAvatars: (v:boolean)=>void;
+ radarWsStatus: WsStatus;
+}) {
+ return (
+
+
Live Radar
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/components/radar/StaticEffects.tsx b/src/app/components/radar/StaticEffects.tsx
index e013612..6f5d900 100644
--- a/src/app/components/radar/StaticEffects.tsx
+++ b/src/app/components/radar/StaticEffects.tsx
@@ -69,6 +69,7 @@ export default function StaticEffects({
unitsToPx,
ui,
beepState,
+ bombFinal10
}: {
grenades: Grenade[]
bomb: BombState | null
@@ -76,6 +77,7 @@ export default function StaticEffects({
unitsToPx: (u:number)=>number
ui: UIShape
beepState: { key: number; dur: number } | null
+ bombFinal10?: boolean
}) {
const smokeNode = (g: Grenade) => {
@@ -306,20 +308,27 @@ export default function StaticEffects({
return (
+ {/* PING-Ring (expandiert + fadet), Takt aus beepState */}
{isActive && beepState && (
)}
+ {/* dezente Grundscheibe */}
+ {/* Icon via Maske */}
-
> | null
+ weapons?: { name: string; state?: string | null }[] | null
+}
+
+const EQUIP_BASE = '/assets/img/icons/equipment'
+const equipIcon = (file: string) => `${EQUIP_BASE}/${file}`
+
+/* ── Inline SVG Icons (weiß via currentColor) ── */
+const HeartIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
+
+)
+
+const ShieldIcon = ({ className = 'w-3.5 h-3.5' }: { className?: string }) => (
+
+)
+
+/* ── Rotes Bomben-Icon via CSS-Maske, damit es sicher rot ist ── */
+const BombMaskIcon = ({ src, title, className = 'h-3.5 w-3.5' }: { src: string; title?: string; className?: string }) => (
+
+)
+
+/* ── Gear Blöcke (links/rechts trennen) ── */
+function leftGear(opts: { armor?: number|null; helmet?: boolean|null }) {
+ const out: { src: string; title: string; key: string }[] = []
+ if ((opts.armor ?? 0) > 0) out.push({ src: equipIcon('armor.svg'), title: 'Kevlar', key: 'armor' })
+ if (opts.helmet) out.push({ src: equipIcon('helmet.svg'), title: 'Helmet', key: 'helmet' })
+ return out
+}
+function rightGear(opts: { hasBomb?: boolean|null; team: Team; defuse?: boolean|null }) {
+ const out: { src: string; title: string; key: string }[] = []
+ if (opts.hasBomb) out.push({ src: equipIcon('c4.svg'), title: 'C4', key: 'c4' })
+ if (opts.team === 'CT' && opts.defuse) out.push({ src: equipIcon('defuser.svg'), title: 'Defuse Kit', key: 'defuser' })
+ return out
+}
+
+/* ── Normalisierung ── */
+function normWeaponName(raw?: string | null) {
+ if (!raw) return ''
+ let k = String(raw).toLowerCase().replace(/^weapon_/, '').replace(/\s+/g, '')
+ if (k === 'usp-s' || k === 'usp-silencer') k = 'usp_silencer'
+ if (k === 'm4a1-s' || k === 'm4a1s') k = 'm4a1_silencer'
+ if (k === 'm4a1s_off' || k === 'm4a1-s_off') k = 'm4a1_silencer_off'
+ return k
+}
+function isActiveWeapon(itemName?: string|null, active?: string | { name?: string|null } | null, state?: string|null) {
+ if ((state ?? '').toLowerCase() === 'active') return true
+ const ni = normWeaponName(itemName)
+ const na = typeof active === 'string' ? normWeaponName(active) : normWeaponName(active?.name ?? null)
+ return !!ni && !!na && ni === na
+}
+
+/* ── Sets ── */
+const GRENADE_SET = new Set(['hegrenade','smokegrenade','flashbang','decoy','molotov','incgrenade'])
+const PRIMARY_SET = new Set([
+ 'ak47','aug','sg556','galilar','famas','m4a1','m4a1_silencer','m4a1_silencer_off',
+ 'awp','ssg08','scar20','g3sg1','xm1014','mag7','sawedoff','nova','m249','negev',
+ 'p90','ump45','mp9','mp7','mp5sd','mac10','bizon'
+])
+const SECONDARY_SET = new Set([
+ 'hkp2000','p2000','p250','glock','deagle','elite','usp_silencer','usp_silencer_off',
+ 'fiveseven','cz75a','tec9','revolver','taser'
+])
+
+/* ── Icons ── */
+const WEAPON_ALIAS: Record = {
+ // Pistols
+ 'hkp2000':'hkp2000','p2000':'p2000','p250':'p250','glock':'glock',
+ 'deagle':'deagle','elite':'elite','usp_silencer':'usp_silencer','usp':'usp_silencer',
+ 'usp_silencer_off':'usp_silencer_off','fiveseven':'fiveseven','cz75a':'cz75a','tec9':'tec9','revolver':'revolver',
+ // SMGs
+ 'mac10':'mac10','mp7':'mp7','mp5sd':'mp5sd','mp9':'mp9','bizon':'bizon','ump45':'ump45','p90':'p90',
+ // Rifles
+ 'ak47':'ak47','aug':'aug','sg556':'sg556','galilar':'galilar','famas':'famas',
+ 'm4a1':'m4a1','m4a1_silencer':'m4a1_silencer','m4a1_silencer_off':'m4a1_silencer_off',
+ // Snipers / Heavy / Shotguns
+ 'awp':'awp','ssg08':'ssg08','scar20':'scar20','g3sg1':'g3sg1',
+ 'xm1014':'xm1014','mag7':'mag7','sawedoff':'sawedoff','nova':'nova',
+ 'm249':'m249','negev':'negev',
+ // Grenades / misc
+ 'hegrenade':'hegrenade','incgrenade':'incgrenade','molotov':'molotov',
+ 'smokegrenade':'smokegrenade','flashbang':'flashbang','decoy':'decoy',
+ 'taser':'taser','defuser':'defuser','c4':'c4','planted_c4':'planted_c4',
+ // Knives
+ 'knife':'knife','knife_t':'knife_t','melee':'melee'
+}
+function weaponIconFromName(raw?: string | null): string | null {
+ if (!raw) return null
+ const k = normWeaponName(raw)
+ const file = WEAPON_ALIAS[k]
+ return file ? equipIcon(`${file}.svg`) : null
+}
+
+const GRENADE_DISPLAY_ORDER = ['flashbang','smokegrenade','hegrenade','molotov','incgrenade','decoy'] as const
+function grenadeIconFromKey(k: string): string {
+ switch (k) {
+ case 'hegrenade': return equipIcon('hegrenade.svg')
+ case 'smokegrenade':return equipIcon('smokegrenade.svg')
+ case 'flashbang': return equipIcon('flashbang.svg')
+ case 'decoy': return equipIcon('decoy.svg')
+ case 'molotov': return equipIcon('molotov.svg')
+ case 'incgrenade': return equipIcon('incgrenade.svg')
+ default: return equipIcon('hegrenade.svg')
+ }
+}
+
+function activeWeaponNameOf(w?: string | { name?: string | null } | null): string | null {
+ if (!w) return null
+ if (typeof w === 'string') return w
+ if (typeof w === 'object' && w?.name) return w.name
+ return null
+}
+
+export default function TeamSidebar({
+ team, teamId, players, align = 'left', onHoverPlayer, score, oppScore
+}: {
+ team: Team
+ teamId?: string
+ players: SidebarPlayer[]
+ align?: 'left' | 'right'
+ onHoverPlayer?: (id: string | null) => void
+ score?: number
+ oppScore?: number
+}) {
+ const [teamLogo, setTeamLogo] = useState(null)
+ const [teamApiName, setTeamApiName] = useState(null)
+ const BOT_ICON = '/assets/img/icons/ui/bot.svg'
+ const isBotId = (id: string) => id?.toUpperCase().startsWith('BOT:')
+
+ useEffect(() => {
+ let abort = false
+ ;(async () => {
+ if (!teamId) { setTeamLogo(null); setTeamApiName(null); return }
+ try {
+ const res = await fetch(`/api/team/${teamId}`, { cache: 'no-store' })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const data = await res.json()
+ if (!abort) { setTeamLogo(data?.logo || null); setTeamApiName(data?.name || null) }
+ } catch { if (!abort) { setTeamLogo(null); setTeamApiName(null) } }
+ })()
+ return () => { abort = true }
+ }, [teamId])
+
+ const ensureTeamsLoaded = useAvatarDirectoryStore(s => s.ensureTeamsLoaded)
+ const avatarById = useAvatarDirectoryStore(s => s.byId)
+ const avatarVer = useAvatarDirectoryStore(s => s.version)
+ useEffect(() => { if (teamId) ensureTeamsLoaded([teamId]) }, [teamId, ensureTeamsLoaded])
+
+ const defaultTeamName = team === 'CT' ? 'Counter-Terrorists' : 'Terrorists'
+ const teamName = teamApiName || defaultTeamName
+
+ const teamColor = team === 'CT' ? 'text-blue-400' : 'text-amber-400'
+ 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 fallbackLogo = '/assets/img/logos/cs2.webp'
+ const logoSrc = teamLogo || fallbackLogo
+
+ const aliveCount = players.filter(p => p.alive !== false && (p.hp ?? 1) > 0).length
+ const sorted = [...players].sort((a,b)=>{
+ const al = (b.alive ? 1 : 0) - (a.alive ? 1 : 0); if (al) return al
+ const hp = (b.hp ?? -1) - (a.hp ?? -1); if (hp) return hp
+ return (a.name ?? '').localeCompare(b.name ?? '')
+ })
+
+ return (
+