This commit is contained in:
Linrador 2025-09-13 15:49:05 +02:00
parent 94bbaaa37e
commit e693af798b
6 changed files with 693 additions and 177 deletions

View File

@ -0,0 +1,10 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4355_1206)">
<path d="M29.472 16.576H28.672V13.824C28.672 11.936 27.136 10.368 25.216 10.368H17.152V7.52C18.336 7.07198 19.1681 5.92 19.1681 4.576C19.1681 2.816 17.76 1.408 16.0001 1.408C14.2401 1.408 12.8321 2.81603 12.8321 4.576C12.8321 5.91995 13.664 7.03995 14.8481 7.52V10.368H6.75212C4.86409 10.368 3.29612 11.9041 3.29612 13.824V16.576H2.5282C1.5682 16.576 0.800171 17.344 0.800171 18.304V22.368C0.800171 23.328 1.5682 24.0961 2.5282 24.0961H3.3282V27.1041C3.3282 28.9921 4.86425 30.5601 6.7842 30.5601H25.2482C27.1362 30.5601 28.7042 29.024 28.7042 27.1041V24.0961H29.5042C30.4642 24.0961 31.2322 23.328 31.2322 22.368L31.2321 18.304C31.2001 17.3761 30.4321 16.5761 29.4721 16.5761L29.472 16.576ZM8.57601 17.888C8.57601 16.448 9.72798 15.296 11.168 15.296C12.608 15.296 13.76 16.448 13.76 17.888C13.76 19.328 12.608 20.48 11.168 20.48C9.72798 20.48 8.57601 19.328 8.57601 17.888ZM18.528 26.016H13.472C12.832 26.016 12.32 25.504 12.32 24.864C12.32 24.224 12.832 23.712 13.472 23.712H18.496C19.136 23.712 19.648 24.224 19.648 24.864C19.6801 25.5039 19.1361 26.016 18.5281 26.016H18.528ZM20.832 20.48C19.392 20.48 18.24 19.328 18.24 17.888C18.24 16.448 19.392 15.296 20.832 15.296C22.272 15.296 23.4239 16.448 23.4239 17.888C23.4239 19.328 22.272 20.48 20.832 20.48Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_4355_1206">
<rect width="32" height="32" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,4 +1,4 @@
// src/app/api/team/[teamId]/route.ts
// /src/app/api/team/[teamId]/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import type { Player, InvitedPlayer } from '@/app/types/team'

View File

@ -17,7 +17,8 @@ type Store = {
loading: Set<string>
version: number
upsert: (u: AvatarUser | NotFound) => void
ensureLoaded: (steamIds: string[]) => Promise<void>
ensureLoaded: (steamIds: string[]) => Promise<void> // (bestehender Fallback)
ensureTeamsLoaded: (teamIds: string[]) => Promise<void> // <-- NEU
}
const isValidSteamId = (s: string) => /^\d{17}$/.test(s)
@ -42,12 +43,13 @@ export const useAvatarDirectoryStore = create<Store>((set, get) => ({
return { byId: { ...s.byId, [u.steamId]: u }, version: s.version + 1 }
}),
// Fallback: einzelne User (kann bleiben)
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(isValidSteamId)
.filter((id) => !state.byId[id] && !state.loading.has(id))
if (need.length === 0) return
@ -81,12 +83,11 @@ export const useAvatarDirectoryStore = create<Store>((set, get) => ({
})
}
} catch {
// Netzfehler ignorieren → später erneut versuchen
// ignorieren → später erneut versuchen
} finally {
await runNext()
}
}
await Promise.all(Array.from({ length: Math.min(pool, need.length) }, () => runNext()))
} finally {
set((s) => {
@ -96,4 +97,57 @@ export const useAvatarDirectoryStore = create<Store>((set, get) => ({
})
}
},
// NEU: ganze Teams laden und Spieler upserten
ensureTeamsLoaded: async (teamIds: string[]) => {
const uniqueTeamIds = Array.from(new Set(teamIds.filter(Boolean).map(String)))
if (uniqueTeamIds.length === 0) return
// Wir markieren kurz die Team-IDs im loading-Set, damit parallele Aufrufe sich nicht stören.
set((s) => {
const loading = new Set(s.loading)
uniqueTeamIds.forEach((tid) => loading.add(`team:${tid}`))
return { loading }
})
try {
await Promise.all(
uniqueTeamIds.map(async (teamId) => {
try {
const res = await fetch(`/api/team/${encodeURIComponent(teamId)}`, { cache: 'no-store' })
if (!res.ok) return
const data = await res.json()
// Sammle alle Spielerquellen aus der Team-API
const buckets: any[] = []
if (data?.leader) buckets.push(data.leader)
if (Array.isArray(data?.activePlayers)) buckets.push(...data.activePlayers)
if (Array.isArray(data?.inactivePlayers)) buckets.push(...data.inactivePlayers)
if (Array.isArray(data?.invitedPlayers)) buckets.push(...data.invitedPlayers)
for (const u of buckets) {
const steamId = String(u?.steamId ?? '')
if (!isValidSteamId(steamId)) continue
get().upsert({
steamId,
name: u?.name ?? null,
avatar: u?.avatar ?? null,
// optionale Felder sind in der Team-API nicht unbedingt vorhanden
status: undefined,
lastActiveAt: null,
})
}
} catch {
// still
}
})
)
} finally {
set((s) => {
const loading = new Set(s.loading)
uniqueTeamIds.forEach((tid) => loading.delete(`team:${tid}`))
return { loading }
})
}
},
}))

View File

@ -23,35 +23,46 @@ export default function GameSocket(props: GameSocketProps) {
const shouldReconnectRef = useRef(true)
const dispatch = (msg: any) => {
if (!msg) return
if (msg.type === 'round_start') { onRoundStart?.(); return }
if (msg.type === 'round_end') { onRoundEnd?.(); return }
if (!msg) return;
if (msg.type === 'round_start') { onRoundStart?.(); return; }
if (msg.type === 'round_end') { onRoundEnd?.(); return; }
if (msg.type === 'tick') {
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase())
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}))
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase());
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}));
const g = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles
if (g) onGrenades?.(g)
const g = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles;
if (g) onGrenades?.(g);
if (msg.bomb) onBomb?.(msg.bomb)
onPlayersAll?.(msg)
return
if (msg.bomb) onBomb?.(msg.bomb);
onPlayersAll?.(msg);
return;
}
// non-tick:
const g2 = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles
if (g2 && msg.type !== 'tick') onGrenades?.(g2)
// --- non-tick messages (hello, map, bomb_* events, etc.) ---
if (msg.map && typeof msg.map.name === 'string') onMap?.(msg.map.name.toLowerCase())
if (msg.allplayers) onPlayersAll?.(msg)
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg)
if (msg.grenades && msg.type !== 'tick') onGrenades?.(msg.grenades)
const t = String(msg.type || '').toLowerCase()
if (msg.bomb || msg.c4 || t.startsWith('bomb_')) onBomb?.(msg)
// 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);
};
useEffect(() => {
if (!url) return
shouldReconnectRef.current = true

View File

@ -30,7 +30,7 @@ const UI = {
},
nade: {
stroke: '#111111',
smokeFill: 'rgba(160,160,160,0.35)',
smokeFill: 'rgba(120,140,160,0.45)',
fireFill: 'rgba(255,128,0,0.35)',
heFill: 'rgba(90,160,90,0.9)',
flashFill: 'rgba(255,255,255,0.95)',
@ -73,6 +73,12 @@ const steamIdOf = (src: any): string | null => {
return null
}
const teamIdT = /* z.B. aus deinem State/Store */ undefined as string | undefined
const teamIdCT = /* z.B. aus deinem State/Store */ undefined as string | undefined
const BOT_ICON = '/assets/img/icons/ui/bot.svg'
const isBotId = (id: string) => id?.toUpperCase().startsWith('BOT:')
function contrastStroke(hex: string) {
const h = hex.replace('#','')
const r = parseInt(h.slice(0,2),16)/255
@ -199,6 +205,9 @@ type Grenade = {
headingRad?: number | null // Rotation fürs Icon (aus velocity)
spawnedAt?: number | null // für kurze Explosion-Animation
ownerId?: string | null // <- NEU: Werfer (SteamID)
effectTimeSec?: number // Sekunden seit Effektdrop (0 bei projectile)
lifeElapsedMs?: number // vergangene ms seit Effektstart
lifeLeftMs?: number // verbleibende ms bis expiresAt
}
type DeathMarker = { id: string; sid?: string | null; x: number; y: number; t: number }
@ -236,6 +245,7 @@ export default function LiveRadar() {
// Deaths
const deathSeqRef = useRef(0)
const deathSeenRef = useRef<Set<string>>(new Set())
const lastAlivePosRef = useRef<Map<string, {x:number,y:number}>>(new Map())
// Grenaden + Trails
const grenadesRef = useRef<Map<string, Grenade>>(new Map())
@ -252,7 +262,7 @@ export default function LiveRadar() {
const [bomb, setBomb] = useState<BombState | null>(null)
// Avatare: Store (lädt /api/user/[steamId])
const ensureAvatars = useAvatarDirectoryStore(s => s.ensureLoaded)
const ensureTeamsLoaded = useAvatarDirectoryStore(s => s.ensureTeamsLoaded)
const avatarVersion = useAvatarDirectoryStore(s => s.version) // Re-Render wenn Avatare kommen
const avatarById = useAvatarDirectoryStore(s => s.byId)
@ -263,8 +273,9 @@ export default function LiveRadar() {
// Spieler-IDs → Avatare laden (Store dedupliziert/limitiert)
useEffect(() => {
if (players.length) ensureAvatars(players.map(p => p.id)) // p.id = SteamID
}, [players, ensureAvatars])
const ids = [teamIdT, teamIdCT].filter(Boolean) as string[]
if (ids.length) ensureTeamsLoaded(ids) // preload beide Teams
}, [teamIdT, teamIdCT, ensureTeamsLoaded])
// Map-Key aus Telemetry übernehmen
const mapKeyFromTelemetry = useTelemetryStore(s => s.mapKey)
@ -300,6 +311,13 @@ export default function LiveRadar() {
by: null, hasKit: false, endsAt: null
})
const addDeathMarkerFor = (id: string, xNow: number, yNow: number) => {
const last = lastAlivePosRef.current.get(id)
const x = Number.isFinite(last?.x) ? last!.x : xNow
const y = Number.isFinite(last?.y) ? last!.y : yNow
addDeathMarker(x, y, id)
}
// Kleiner Ticker, damit die Anzeigen "laufen"
const [, forceTick] = useState(0)
useEffect(() => {
@ -334,8 +352,14 @@ export default function LiveRadar() {
deathSeenRef.current.clear()
trailsRef.current.clear()
grenadesRef.current.clear()
lastAlivePosRef.current.clear()
bombRef.current = null
// 👇 Projektil-ID-Cache säubern
projectileIdCache.clear()
projectileIdReverse.clear()
projectileSeq = 0
if (hard) {
playersRef.current.clear()
} else if (resetPlayers) {
@ -346,6 +370,7 @@ export default function LiveRadar() {
scheduleFlush()
}
useEffect(() => {
if (activeMapKey) clearRoundArtifacts(true, true)
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -448,6 +473,15 @@ export default function LiveRadar() {
if ((old?.alive !== false) && nextAlive === false) addDeathMarker(x, y, id)
const isAliveProbe = nextAlive // boolean | undefined
if (isAliveProbe === true) {
// lebend: letzte lebend-Pos aktualisieren
lastAlivePosRef.current.set(id, { x, y })
} else if (isAliveProbe === false && (old?.alive !== false)) {
// gerade gestorben: Marker an letzte lebend-Pos (Fallback: aktuelle)
addDeathMarkerFor(id, x, y)
}
playersRef.current.set(id, {
id,
name: e.name ?? old?.name ?? null,
@ -466,6 +500,12 @@ export default function LiveRadar() {
const handlePlayersAll = (msg: any) => {
// --- Rundenphase & Ende (läuft IMMER, auch wenn keine Player-Daten) ---
const pcd = msg?.phase ?? msg?.phase_countdowns
const phase = String(pcd?.phase ?? '').toLowerCase()
if (phase === 'freezetime' && (deathMarkersRef.current.length || trailsRef.current.size)) {
clearRoundArtifacts(true) // Spieler am Leben lassen, Granaten nicht löschen
}
if (pcd?.phase_ends_in != null) {
const sec = Number(pcd.phase_ends_in)
if (Number.isFinite(sec)) {
@ -541,7 +581,7 @@ export default function LiveRadar() {
}
if (total > 0 && aliveCount === total &&
(deathMarkersRef.current.length > 0 || trailsRef.current.size > 0 || grenadesRef.current.size > 0)) {
(deathMarkersRef.current.length > 0 || trailsRef.current.size > 0)) {
clearRoundArtifacts()
}
@ -582,149 +622,366 @@ export default function LiveRadar() {
setScore({ ct, t, round: rnd })
} catch {}
for (const p of playersRef.current.values()) {
// Marker nur, wenn wir valide Koordinaten haben
if (p.alive === false && Number.isFinite(p.x) && Number.isFinite(p.y)) {
addDeathMarker(p.x, p.y, p.id) // dedup über deathSeenRef
}
}
for (const p of playersRef.current.values()) {
if (p.alive === false) {
const last = lastAlivePosRef.current.get(p.id)
const x = Number.isFinite(last?.x) ? last!.x : p.x
const y = Number.isFinite(last?.y) ? last!.y : p.y
if (Number.isFinite(x) && Number.isFinite(y)) addDeathMarker(p.x, p.y, p.id) // <- ersetze durch:
// addDeathMarker(x, y, p.id)
}
}
scheduleFlush()
}
const normalizeGrenades = (raw: any): Grenade[] => {
const out: Grenade[] = []
const now = Date.now()
// ── Modul-Scope: stabile IDs für Projektile ─────────────────────────
const projectileIdCache = new Map<string, string>(); // key -> id
const projectileIdReverse = new Map<string, string>(); // id -> key
let projectileSeq = 0;
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
const s = mapTeam(t)
return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? s : null)
// ── Normalizer ──────────────────────────────────────────────────────
function normalizeGrenades(raw: any): Grenade[] {
const now = Date.now();
// ---- Helpers -----------------------------------------------------
const asNum = (n: any, d = 0) => { const v = Number(n); return Number.isFinite(v) ? v : d; };
const parseVec3String = (str?: string) => {
if (!str || typeof str !== 'string') return { x: 0, y: 0, z: 0 };
const [x, y, z] = str.split(',').map(s => Number(s.trim()));
return { x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0, z: Number.isFinite(z) ? z : 0 };
};
const parseVec3Loose = (v: any) => {
// akzeptiert {x,y,z}, [x,y,z], "x, y, z"
if (!v) return { x: 0, y: 0, z: 0 };
if (Array.isArray(v)) return { x: asNum(v[0]), y: asNum(v[1]), z: asNum(v[2]) };
if (typeof v === 'string') return parseVec3String(v);
return { x: asNum(v.x), y: asNum(v.y), z: asNum(v.z) };
};
const parseVel = (g: any) => {
const v = g?.vel ?? g?.velocity ?? g?.dir ?? g?.forward ?? null;
return parseVec3Loose(v);
};
const toKind = (s: string): Grenade['kind'] => {
const k = s.toLowerCase();
if (k.includes('smoke')) return 'smoke';
if (k.includes('inferno') || k.includes('molotov') || k.includes('firebomb') || k.includes('incendiary') || k === 'fire') {
return k.includes('incendiary') ? 'incendiary' : 'molotov';
}
if (k.includes('flash')) return 'flash';
if (k.includes('decoy')) return 'decoy';
if (k.includes('he') || k.includes('frag') || k.includes('explosive')) return 'he';
return 'unknown';
};
// Helper: baue eine Grenade
const make = (g:any, kindIn:string, phase:'projectile'|'effect'|'exploded'): Grenade => {
const ownerRaw =
g?.owner ?? g?.thrower ?? g?.player ?? g?.shooter ??
{ steamId: g?.ownerSteamId ?? g?.steamid ?? g?.steam_id ?? g?.owner_id }
const ownerId = steamIdOf(ownerRaw)
const kind = (kindIn.toLowerCase() as Grenade['kind'])
const pos = g?.pos ?? g?.position ?? g?.location
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
typeof pos === 'string' ? parseVec3String(pos) :
(pos || { x: g?.x, y: g?.y, z: g?.z })
// Heading aus velocity/forward
const vel = g?.vel ?? g?.velocity ?? g?.speed ?? g?.dir ?? g?.forward
let headingRad: number | null = null
if (vel && (vel.x !== 0 || vel.y !== 0)) headingRad = Math.atan2(vel.y, vel.x)
// Radius defaults für Effektphase
const defR =
const defaultRadius = (kind: Grenade['kind']) =>
kind === 'smoke' ? 150 :
(kind === 'molotov' || kind === 'incendiary') ? 120 :
kind === 'he' ? 280 : // für visuellen Burst
kind === 'he' ? 280 :
kind === 'flash' ? 36 :
kind === 'decoy' ? 80 : 60
kind === 'decoy' ? 80 : 60;
return {
id: String(g?.id ?? g?.entityid ?? g?.entindex ?? `${kind}#${asNum(xyz?.x)}:${asNum(xyz?.y)}:${asNum(xyz?.z)}:${phase}`),
kind,
x: asNum(xyz?.x), y: asNum(xyz?.y), z: asNum(xyz?.z),
radius: Number.isFinite(Number(g?.radius)) ? Number(g?.radius) : defR,
expiresAt: Number.isFinite(Number(g?.expiresAt)) ? Number(g?.expiresAt) : null,
team: pickTeam(g?.team ?? g?.owner_team ?? g?.side ?? null),
phase,
headingRad,
spawnedAt: now,
ownerId,
// Owner/SteamID robust lesen (gleiche Logik wie sonst im Code)
const steamIdOf = (src: any): string | null => {
const raw = src?.steamId ?? src?.steam_id ?? src?.steamid ?? src?.id ?? src?.entityId ?? src?.entindex ?? src?.userid;
const s = raw != null ? String(raw) : '';
if (/^\d{17}$/.test(s)) return s;
const name = (src?.name ?? src?.playerName ?? '').toString().trim();
if (name) return `BOT:${name}`;
if (s && s !== '0' && s.toUpperCase() !== 'BOT') return s;
return null;
};
// ---- Item→Grenade ------------------------------------------------
const makeFromItem = (g: any, kHint: string | null, phaseHint: Grenade['phase'] | null): Grenade => {
// Owner
const ownerRaw =
g?.owner ?? g?.thrower ?? g?.player ?? g?.shooter ?? g?.user ?? g?.attacker ?? g?.killer ??
{ steamId: g?.ownerSteamId ?? g?.owner_steamid ?? g?.steamid ?? g?.steam_id ?? g?.owner_id ?? g?.userid };
const ownerId = steamIdOf(ownerRaw);
// Art
const rawKind = String(g?.kind ?? g?.type ?? g?.weapon ?? kHint ?? 'unknown');
const kind = toKind(rawKind);
// Position
const posSrc = g?.pos ?? g?.position ?? g?.location ?? g?.origin ?? g;
const { x, y, z } = parseVec3Loose(posSrc);
// Heading (aus velocity)
const V = parseVel(g);
const headingRad = (V.x || V.y)
? Math.atan2(Number(V.y), Number(V.x))
: (Number.isFinite(g?.headingRad) ? Number(g.headingRad) : null);
// Phase bestimmen
let phase: Grenade['phase'] =
(kind === 'he' && (g?.exploded || String(g?.state ?? '').toLowerCase() === 'exploded')) ? 'exploded'
: (phaseHint ?? ((g?.expiresAt != null) ? 'effect' : 'projectile'));
// Radius / Zeiten
const radius = Number.isFinite(Number(g?.radius)) ? Number(g.radius) : defaultRadius(kind);
const spawnedAt = Number.isFinite(Number(g?.spawnedAt)) ? Number(g.spawnedAt) : now;
let expiresAt: number | null = null;
if (g?.expiresAt != null && Number.isFinite(Number(g.expiresAt))) {
expiresAt = Number(g.expiresAt);
} else {
// Standard-Laufzeiten — Smoke hier +1s länger (19s)
const lifeMs =
kind === 'smoke' ? 19_000 :
(kind === 'molotov' || kind === 'incendiary') ? 7_000 :
kind === 'flash' ? 300 :
kind === 'he' ? (phase === 'exploded' ? 350 : 300) :
kind === 'decoy' ? 15_000 : 2_000;
if (phase === 'effect' || kind === 'he') {
expiresAt = spawnedAt + lifeMs;
}
}
// 1) Projektile-Listen (versch. Namen)
const projLists = raw?.projectiles ?? raw?.grenadeProjectiles ?? raw?.nades ?? raw?.flying
if (projLists) {
const arr = Array.isArray(projLists) ? projLists : Object.values(projLists)
for (const g of arr) {
const k = String(g?.type ?? g?.kind ?? g?.weapon ?? 'unknown').toLowerCase()
const kind =
k.includes('smoke') ? 'smoke' :
(k.includes('molotov') || k.includes('incendiary') || k.includes('fire')) ? (k.includes('incendiary') ? 'incendiary' : 'molotov') :
k.includes('flash') ? 'flash' :
k.includes('decoy') ? 'decoy' :
(k.includes('he') || k.includes('frag')) ? 'he' : 'unknown'
out.push(make(g, kind, 'projectile'))
// Team
const teamRaw = (g?.team ?? g?.owner_team ?? g?.side ?? g?.teamnum ?? g?.team_num ?? '').toString().toUpperCase();
const team = teamRaw === 'T' || teamRaw === 'CT' ? teamRaw : null;
// ── STABILE ID für Projektile ──────────────────────────────────
const givenId = g?.id ?? g?.entityid ?? g?.entindex;
let id: string;
let cacheKey: string | null = null;
if (givenId != null) {
id = String(givenId); // Engine-ID ist stabil
} else if (phase === 'projectile') {
// Key aus Owner, Kind, quantisierter Spawnzeit
const born = Number.isFinite(+g?.spawnedAt) ? +g.spawnedAt : now;
cacheKey = `${ownerId ?? 'u'}|${kind}|${Math.floor(born / 100)}`; // 100-ms Bucket
const hit = projectileIdCache.get(cacheKey);
if (hit) {
id = hit;
} else {
id = `proj#${kind}:${++projectileSeq}`;
projectileIdCache.set(cacheKey, id);
projectileIdReverse.set(id, cacheKey);
}
} else {
// Effekte dürfen positionsbasiert sein
id = `${kind}#${Math.round(x)}:${Math.round(y)}:${Math.round(z)}:${phase}`;
}
// Smoke-spezifische Zusatzwerte (mit 19s Default)
let effectTimeSec: number | undefined;
let lifeElapsedMs: number | undefined;
let lifeLeftMs: number | undefined;
if (kind === 'smoke') {
const lifeMsDefault = 19_000;
const eff = Number(g?.effectTimeSec);
if (Number.isFinite(eff)) {
effectTimeSec = eff;
} else if (phase === 'effect') {
const bornAt = (g?.spawnedAt && Number.isFinite(+g.spawnedAt))
? +g.spawnedAt
: (expiresAt ? (expiresAt - lifeMsDefault) : now);
effectTimeSec = Math.max(0, (now - bornAt) / 1000);
} else {
effectTimeSec = 0;
}
if (Number.isFinite(+g?.lifeElapsedMs)) {
lifeElapsedMs = +g.lifeElapsedMs;
} else {
lifeElapsedMs = Math.max(0, (effectTimeSec ?? 0) * 1000);
}
if (Number.isFinite(+g?.lifeLeftMs)) {
lifeLeftMs = +g.lifeLeftMs;
} else {
const bornAt = now - (lifeElapsedMs ?? 0);
const expAt = (expiresAt ?? (bornAt + lifeMsDefault));
lifeLeftMs = Math.max(0, expAt - now);
}
}
// 2) Effekt-Listen (stehende Wolke/Feuer etc.)
const buckets: Record<string, string[]> = {
const ret: Grenade & { _cacheKey?: string } = {
id, kind, x, y, z,
radius,
expiresAt: expiresAt ?? undefined,
team, phase, headingRad, spawnedAt, ownerId,
effectTimeSec, lifeElapsedMs, lifeLeftMs
};
if (cacheKey) ret._cacheKey = cacheKey;
return ret;
};
// ---- 1) Server liefert bereits normalisierte Liste ----------------
if (Array.isArray(raw) && raw.length && raw.every(n => n && n.id && n.kind)) {
return raw.map((n) => makeFromItem(n, String(n.kind), n.phase ?? null));
}
// ---- 2) Buckets/Mixed: GSI-ähnliche Formate -----------------------
const out: Grenade[] = [];
if (raw && typeof raw === 'object') {
// Projektile
const proj = raw?.projectiles ?? raw?.grenadeProjectiles ?? raw?.nades ?? raw?.flying;
if (proj) {
const arr = Array.isArray(proj) ? proj : Object.values(proj);
for (const g of arr) out.push(makeFromItem(g, String(g?.type ?? g?.kind ?? g?.weapon ?? 'unknown'), 'projectile'));
}
// Effekt-Buckets
const buckets: Record<Grenade['kind'], string[]> = {
smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'],
molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'],
molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'firebomb'],
he: ['he', 'hegrenade', 'hegrenades', 'explosive'],
flash: ['flash', 'flashbang', 'flashbangs'],
decoy: ['decoy', 'decoys'],
incendiary: ['incendiary', 'incgrenade'] // falls getrennt geliefert
}
incendiary: ['incendiary', 'incgrenade'],
unknown: []
};
const pushEffects = (kind: Grenade['kind'], list:any) => {
const arr = Array.isArray(list) ? list : Object.values(list)
for (const g of arr) out.push(make(g, kind, kind === 'he' && (g?.exploded || g?.state === 'exploded') ? 'exploded' : 'effect'))
const pushEffects = (kind: Grenade['kind'], list: any) => {
const arr = Array.isArray(list) ? list : Object.values(list);
for (const g of arr) {
const ph: Grenade['phase'] =
(kind === 'he' && (g?.exploded || String(g?.state ?? '').toLowerCase() === 'exploded'))
? 'exploded' : 'effect';
out.push(makeFromItem(g, kind, ph));
}
};
if (typeof raw === 'object') {
for (const [kind, keys] of Object.entries(buckets)) {
for (const k of keys) if ((raw as any)[k]) pushEffects(kind as Grenade['kind'], (raw as any)[k])
for (const k of keys) if ((raw as any)[k]) pushEffects(kind as Grenade['kind'], (raw as any)[k]);
}
// Speziell: inferno/flames → Mittelpunkt bilden
if (raw?.inferno && typeof raw.inferno === 'object') {
const arr = Array.isArray(raw.inferno) ? raw.inferno : Object.values(raw.inferno);
for (const g of arr) {
const flames = g?.flames && typeof g.flames === 'object' ? Object.values(g.flames) : null;
if (!flames || flames.length === 0) continue;
let sx = 0, sy = 0, sz = 0, n = 0;
for (const f of flames) {
const p = parseVec3Loose(f);
if (Number.isFinite(p.x) && Number.isFinite(p.y)) { sx += p.x; sy += p.y; sz += p.z; n++; }
}
if (n > 0) {
const center = { x: sx / n, y: sy / n, z: sz / n };
out.push(makeFromItem({ ...g, position: center }, 'inferno', 'effect'));
}
}
}
}
// 3) Falls raw ein Array ist (gemischt)
if (Array.isArray(raw)) {
// ---- 3) Gemischtes Array -----------------------------------------
if (Array.isArray(raw) && out.length === 0) {
for (const g of raw) {
const k = String(g?.type ?? g?.kind ?? 'unknown').toLowerCase()
const isEffect = (g?.expiresAt != null) || (g?.state && String(g.state).toLowerCase() !== 'projectile')
const hint = String(g?.type ?? g?.kind ?? g?.weapon ?? 'unknown');
const isEffect = (g?.expiresAt != null) || (g?.state && String(g.state).toLowerCase() !== 'projectile');
const phase: Grenade['phase'] =
k.includes('he') && (g?.exploded || g?.state === 'exploded') ? 'exploded' :
isEffect ? 'effect' : 'projectile'
const kind =
k.includes('smoke') ? 'smoke' :
(k.includes('molotov') || k.includes('incendiary') || k.includes('fire')) ? (k.includes('incendiary') ? 'incendiary' : 'molotov') :
k.includes('flash') ? 'flash' :
k.includes('decoy') ? 'decoy' :
k.includes('he') ? 'he' : 'unknown'
out.push(make(g, kind, phase))
toKind(hint) === 'he' && (g?.exploded || String(g?.state ?? '').toLowerCase() === 'exploded')
? 'exploded'
: (isEffect ? 'effect' : 'projectile');
out.push(makeFromItem(g, hint, phase));
}
}
return out
return out;
}
const handleGrenades = (g: any) => {
const list = normalizeGrenades(g)
const now = Date.now();
const list = normalizeGrenades(g); // liefert ggf. ._cacheKey an Projektile
const mine = mySteamId ? list.filter(n => n.ownerId === mySteamId) : []
const seen = new Set<string>()
const now = Date.now()
// ---- Trails nur für eigene fliegende Nades -------------------------
const mine = mySteamId
? list.filter(n => n.ownerId === mySteamId && n.phase === 'projectile')
: [];
const seenTrailIds = new Set<string>();
for (const it of mine) {
seen.add(it.id)
const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 }
const last = prev.pts[prev.pts.length - 1]
seenTrailIds.add(it.id);
const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 };
const last = prev.pts[prev.pts.length - 1];
if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) {
prev.pts.push({ x: it.x, y: it.y })
if (prev.pts.length > UI.trail.maxPoints) prev.pts = prev.pts.slice(-UI.trail.maxPoints)
prev.pts.push({ x: it.x, y: it.y });
if (prev.pts.length > UI.trail.maxPoints) prev.pts = prev.pts.slice(-UI.trail.maxPoints);
}
prev.kind = it.kind
prev.lastSeen = now
trailsRef.current.set(it.id, prev)
prev.kind = it.kind;
prev.lastSeen = now;
trailsRef.current.set(it.id, prev);
}
for (const [id, tr] of trailsRef.current) {
if (!seen.has(id) && now - tr.lastSeen > UI.trail.fadeMs) trailsRef.current.delete(id)
if (!seenTrailIds.has(id) && now - tr.lastSeen > UI.trail.fadeMs) {
trailsRef.current.delete(id);
}
}
const next = new Map<string, Grenade>()
for (const it of mine) next.set(it.id, it)
grenadesRef.current = next
// ---- Sanftes Mergen + Aufräumen -----------------------------------
const GRACE_PROJECTILE_MS = 0; // Schonfrist, falls ein Tick fehlt
const next = new Map<string, Grenade & { _lastSeen?: number; _cacheKey?: string }>(grenadesRef.current as any);
const seenIds = new Set<string>();
scheduleFlush()
// Merge/Upsert aktuelle Liste
for (const it of list) {
seenIds.add(it.id);
const prev = next.get(it.id);
const merged: any = {
...prev,
...it,
spawnedAt: prev?.spawnedAt ?? it.spawnedAt ?? now,
headingRad: (it.headingRad ?? prev?.headingRad ?? null),
_lastSeen: now,
// _cacheKey kommt von normalizeGrenades (nur Projektile)
_cacheKey: (it as any)._cacheKey ?? (prev as any)?._cacheKey
};
next.set(it.id, merged);
}
// Cleanup: Effekte nach Ablauf; Projektile nach Schonfrist (+ Cache leeren)
for (const [id, nade] of next) {
const lastSeen = (nade as any)._lastSeen as number | undefined;
if (nade.phase === 'effect' || nade.phase === 'exploded') {
const left = (typeof nade.lifeLeftMs === 'number')
? nade.lifeLeftMs
: (typeof nade.expiresAt === 'number' ? (nade.expiresAt - Date.now()) : null);
if (left != null && left <= 0) {
next.delete(id);
}
continue;
}
if (nade.phase === 'projectile') {
if (!seenIds.has(id)) {
const tooOld = !lastSeen || (now - lastSeen > GRACE_PROJECTILE_MS);
if (tooOld) {
// 🔻 Cache-Cleanup, damit die ID nicht „kleben“ bleibt
const key = (nade as any)._cacheKey ?? projectileIdReverse.get(id);
if (key) projectileIdCache.delete(key);
projectileIdReverse.delete(id);
next.delete(id);
}
}
}
}
grenadesRef.current = next;
scheduleFlush();
};
useEffect(() => {
if (!playersRef.current && !grenadesRef.current) return
scheduleFlush()
@ -954,6 +1211,28 @@ export default function LiveRadar() {
)
}
const teamOfPlayer = (sid?: string | null): 'T' | 'CT' | string | null => {
if (!sid) return null;
return playersRef.current.get(sid)?.team ?? null;
};
const teamOfGrenade = (g: Grenade): 'T' | 'CT' | string | null => {
if (g.team === 'T' || g.team === 'CT') return g.team;
const ownerTeam = teamOfPlayer(g.ownerId);
return ownerTeam === 'T' || ownerTeam === 'CT' ? ownerTeam : null;
};
const shouldShowGrenade = (g: Grenade): boolean => {
// Kein zugeordnetes Team des eingeloggten Users -> alles zeigen
if (myTeam !== 'T' && myTeam !== 'CT') return true;
// Team der Nade bestimmen
const gt = teamOfGrenade(g);
// Nur Nades des eigenen Teams zeigen; unbekannte Teams ausblenden
return gt === myTeam;
};
/* ───────── Render ───────── */
return (
<div className="h-full min-h-0 w-full flex flex-col overflow-hidden">
@ -1002,6 +1281,12 @@ export default function LiveRadar() {
deathMarkersRef.current = []
trailsRef.current.clear()
grenadesRef.current.clear()
// 👇 auch hier aufräumen
projectileIdCache.clear()
projectileIdReverse.clear()
projectileSeq = 0
scheduleFlush()
}}
@ -1038,6 +1323,7 @@ export default function LiveRadar() {
{myTeam !== 'CT' && (
<TeamSidebar
team="T"
teamId={teamIdT}
players={players
.filter(p => p.team === 'T' && (!myTeam || p.team === myTeam))
.map(p => ({
@ -1147,6 +1433,34 @@ export default function LiveRadar() {
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<radialGradient id="smokeRadial" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#B9B9B9" stopOpacity="0.70" />
<stop offset="65%" stopColor="#A0A0A0" stopOpacity="0.35" />
<stop offset="100%" stopColor="#A0A0A0" stopOpacity="0.00" />
</radialGradient>
<linearGradient id="flameGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fff59d"/>
<stop offset="45%" stopColor="#ffd54f"/>
<stop offset="100%" stopColor="#ff7043"/>
</linearGradient>
<symbol id="flameIcon" viewBox="0 0 64 64">
{/* äußere Flamme */}
<path
d="M32 4c6 11-4 14 2 23 3 4 10 7 10 16 0 10-8 17-18 17S8 53 8 43c0-8 5-13 9-17 6-6 8-10 15-22z"
fill="url(#flameGrad)"
/>
{/* innerer, heller Kern */}
<path
d="M33 20c3 6-2 8 1 12 2 2 6 3 6 8 0 5-4 9-10 9s-10-4-10-9c0-4 3-7 5-9 3-3 4-5 8-11z"
fill="#ffffff66"
/>
</symbol>
</defs>
{/* Trails */}
{trails.map(tr => {
const pts = tr.pts.map(p => {
@ -1169,7 +1483,7 @@ export default function LiveRadar() {
{/* Grenades: Projectiles + Effekte */}
{grenades
.filter(g => !mySteamId || g.ownerId === mySteamId) // <- NEU: nur eigene
//.filter(shouldShowGrenade)
.map((g) => {
const P = worldToPx(g.x, g.y)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
@ -1182,11 +1496,11 @@ export default function LiveRadar() {
// 1) Projektil-Icon
if (g.phase === 'projectile') {
const size = Math.max(16, rPx * 0.7)
const href = EQUIP_ICON[g.kind] ?? EQUIP_ICON.unknown
const rot = (g.headingRad ?? 0) * 180 / Math.PI
const size = Math.max(18, 22); // fix/klein, statt radius-basiert (optional)
const href = EQUIP_ICON[g.kind] ?? EQUIP_ICON.unknown;
const rotDeg = Number.isFinite(g.headingRad as number) ? (g.headingRad! * 180 / Math.PI) : 0;
return (
<g key={`nade-proj-${g.id}`} transform={`rotate(${rot} ${P.x} ${P.y})`}>
<g key={`nade-proj-${g.id}`} transform={`rotate(${rotDeg} ${P.x} ${P.y})`}>
<image
href={href}
x={P.x - size/2}
@ -1196,9 +1510,10 @@ export default function LiveRadar() {
preserveAspectRatio="xMidYMid meet"
/>
</g>
)
);
}
// 2) HE-Explosion
if (g.kind === 'he' && g.phase === 'exploded') {
const base = Math.max(18, unitsToPx(22))
@ -1217,11 +1532,66 @@ export default function LiveRadar() {
// 3) Statische Effekte
if (g.kind === 'smoke') {
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
const lifeMs = 18_000
const left = (typeof g.lifeLeftMs === 'number')
? Math.max(0, g.lifeLeftMs)
: (g.expiresAt ? Math.max(0, g.expiresAt - Date.now()) : null)
const frac = left == null ? 1 : Math.min(1, left / lifeMs)
// leichte Aufhellung/Abdunklung via Opacity-Multiplikator
const opacity = 0.35 + 0.45 * frac // 0.35 .. 0.80
return (
<circle
key={g.id}
cx={P.x}
cy={P.y}
r={rPx}
fill={UI.nade.smokeFill}
fillOpacity={opacity}
stroke={stroke}
strokeWidth={sw}
/>
)
}
if (g.kind === 'molotov' || g.kind === 'incendiary') {
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.fireFill} stroke={stroke} strokeWidth={sw} />
const W = Math.max(28, rPx * 1.4);
const H = W * 1.25;
return (
<g key={g.id}>
{/* optionaler Team-Ring */}
<circle
cx={P.x}
cy={P.y}
r={rPx}
fill="none"
stroke={stroke}
strokeWidth={Math.max(1, sw * 0.8)}
strokeDasharray="6,5"
opacity="0.6"
/>
{/* WICHTIG: äußere Gruppe = nur Translate */}
<g transform={`translate(${P.x}, ${P.y})`}>
{/* innere Gruppe = nur Animation */}
<g className="flame-anim">
<use
href="#flameIcon"
x={-W / 2}
y={-H / 2}
width={W}
height={H}
preserveAspectRatio="xMidYMid meet"
opacity="0.95"
/>
</g>
</g>
</g>
);
}
if (g.kind === 'decoy') {
return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.decoyFill} stroke={stroke} strokeWidth={sw} strokeDasharray="6,4" />
}
@ -1328,7 +1698,9 @@ export default function LiveRadar() {
// 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
const avatarUrl = useAvatars
? (isBotId(p.id) ? BOT_ICON : (avatarFromStore || DEFAULT_AVATAR))
: null
// ➜ Avatare größer skalieren
const isAvatar = !!avatarUrl
@ -1342,6 +1714,16 @@ export default function LiveRadar() {
const ringColor = (isAvatar && p.hasBomb) ? UI.player.bombStroke : fillColor
const isBotAvatar = useAvatars && isBotId(p.id)
const innerScale = isBotAvatar ? 0.74 : 1 // "Padding" im Kreis
const imgW = r * 2 * innerScale
const imgH = r * 2 * innerScale
const imgX = A.x - imgW / 2
const imgY = A.y - imgH / 2
const baseBgColor = '#0b0b0b'
const baseBgOpacity = 0.45
return (
<g key={p.id}>
{isAvatar ? (
@ -1352,14 +1734,22 @@ export default function LiveRadar() {
</clipPath>
</defs>
<circle
cx={A.x}
cy={A.y}
r={r}
fill={baseBgColor}
opacity={baseBgOpacity}
/>
<image
href={String(avatarUrl)}
x={A.x - r}
y={A.y - r}
width={r * 2}
height={r * 2}
x={imgX}
y={imgY}
width={imgW}
height={imgH}
clipPath={`url(#${clipId})`}
preserveAspectRatio="xMidYMid slice"
preserveAspectRatio={isBotAvatar ? 'xMidYMid meet' : 'xMidYMid slice'}
crossOrigin="anonymous"
onError={(e) => {
const img = e.currentTarget as SVGImageElement
@ -1501,6 +1891,7 @@ export default function LiveRadar() {
<TeamSidebar
team="CT"
align="right"
teamId={teamIdCT}
players={players
.filter(p => p.team === 'CT' && (!myTeam || p.team === myTeam))
.map(p => ({
@ -1533,6 +1924,26 @@ export default function LiveRadar() {
0% { transform: scale(1); opacity: .85; }
100% { transform: scale(3.4); opacity: 0; }
}
@keyframes smokePulse {
0% { transform: scale(0.98); opacity: 0.92; }
100% { transform: scale(1.03); opacity: 1; }
}
.flame-anim {
transform-box: fill-box;
transform-origin: center;
animation: flameFlicker 900ms ease-in-out infinite alternate,
flameWobble 1800ms ease-in-out infinite;
}
@keyframes flameFlicker {
0% { transform: scale(0.92); filter: brightness(0.95); opacity: 0.92; }
100% { transform: scale(1.06); filter: brightness(1.10); opacity: 1; }
}
@keyframes flameWobble {
0% { transform: rotate(-2deg); }
50% { transform: rotate( 2deg); }
100% { transform: rotate(-2deg); }
}
`}</style>
</div>
)

View File

@ -1,6 +1,6 @@
// /src/app/radar/TeamSidebar.tsx
'use client'
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore'
export type Team = 'T' | 'CT'
@ -17,33 +17,65 @@ export type SidebarPlayer = {
export default function TeamSidebar({
team,
teamId,
players,
align = 'left',
onHoverPlayer,
}: {
team: Team
teamId?: string
players: SidebarPlayer[]
align?: 'left' | 'right'
onHoverPlayer?: (id: string | null) => void
}) {
// Avatar-Directory
const ensureAvatars = useAvatarDirectoryStore(s => s.ensureLoaded)
const avatarById = useAvatarDirectoryStore(s => s.byId)
const avatarVer = useAvatarDirectoryStore(s => s.version) // re-render trigger
// ---- NEU: Team-Info (Logo) laden ----
const [teamLogo, setTeamLogo] = useState<string | null>(null)
const [teamApiName, setTeamApiName] = useState<string | null>(null)
const BOT_ICON = '/assets/img/icons/ui/bot.svg'
const isBotId = (id: string) => id?.toUpperCase().startsWith('BOT:')
// bei Änderungen nachladen (sicher ist sicher; LiveRadar lädt auch)
useEffect(() => {
if (players.length) ensureAvatars(players.map(p => p.id))
}, [players, ensureAvatars])
let abort = false
async function loadTeam() {
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) }
}
}
loadTeam()
return () => { abort = true }
}, [teamId])
// ---- Rest wie gehabt ----
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 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 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
// Fallback-Icon, falls API kein Logo liefert:
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 !== 0) return al
@ -54,26 +86,33 @@ export default function TeamSidebar({
return (
<aside className="h-full min-h-0 flex flex-col rounded-md border border-neutral-700/60 bg-neutral-900/30 p-2 overflow-hidden">
{/* Header mit Logo + Name */}
<div className="flex items-center justify-between text-xs uppercase tracking-wide opacity-80">
<span className={`font-semibold ${teamColor}`}>{teamName}</span>
<span className={`font-semibold flex items-center gap-2 ${teamColor}`}>
<img
src={logoSrc}
alt={teamName}
className="w-4 h-4 object-contain"
/>
{teamName}
</span>
<span className="tabular-nums">{aliveCount}/{players.length}</span>
</div>
{/* ... Rest der Komponente bleibt unverändert ... */}
<div className="mt-2 flex-1 overflow-auto space-y-3 pr-1">
{sorted.map(p => {
void avatarVer // re-render, wenn Avatare eintrudeln
void avatarVer
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
// Avatar aus Store (Fallback: Default)
const entry = avatarById[p.id] as any
const avatarUrl =
entry && !(entry as any)?.notFound && entry?.avatar
isBotId(p.id) // <- Bot? dann Bot-Icon
? BOT_ICON
: (entry && !entry?.notFound && entry?.avatar
? entry.avatar
: '/assets/img/avatars/default_steam_avatar.jpg'
// Layout: Avatar neben Stack(Name+Bars); rechts gespiegelt bei align="right"
: '/assets/img/avatars/default_steam_avatar.jpg')
const rowDir = isRight ? 'flex-row-reverse text-right' : 'flex-row'
const stackAlg = isRight ? 'items-end' : 'items-start'
@ -83,26 +122,21 @@ export default function TeamSidebar({
id={`player-${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`}
className={`w-12 h-12 rounded-full border border-white/10 ring-2 ${ringColor} bg-neutral-900 object-contain p-1`}
width={48}
height={48}
loading="lazy"
/>
{/* 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>}
@ -110,14 +144,10 @@ export default function TeamSidebar({
{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>