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 { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/app/lib/prisma'
import type { Player, InvitedPlayer } from '@/app/types/team' import type { Player, InvitedPlayer } from '@/app/types/team'

View File

@ -17,7 +17,8 @@ type Store = {
loading: Set<string> loading: Set<string>
version: number version: number
upsert: (u: AvatarUser | NotFound) => void 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) 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 } return { byId: { ...s.byId, [u.steamId]: u }, version: s.version + 1 }
}), }),
// Fallback: einzelne User (kann bleiben)
ensureLoaded: async (steamIds: string[]) => { ensureLoaded: async (steamIds: string[]) => {
const state = get() const state = get()
const unique = Array.from(new Set(steamIds.map(String))) const unique = Array.from(new Set(steamIds.map(String)))
const need = unique const need = unique
.filter(Boolean) .filter(Boolean)
.filter(isValidSteamId) // ✅ nur echte SteamIDs .filter(isValidSteamId)
.filter((id) => !state.byId[id] && !state.loading.has(id)) .filter((id) => !state.byId[id] && !state.loading.has(id))
if (need.length === 0) return if (need.length === 0) return
@ -81,12 +83,11 @@ export const useAvatarDirectoryStore = create<Store>((set, get) => ({
}) })
} }
} catch { } catch {
// Netzfehler ignorieren → später erneut versuchen // ignorieren → später erneut versuchen
} finally { } finally {
await runNext() await runNext()
} }
} }
await Promise.all(Array.from({ length: Math.min(pool, need.length) }, () => runNext())) await Promise.all(Array.from({ length: Math.min(pool, need.length) }, () => runNext()))
} finally { } finally {
set((s) => { 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 shouldReconnectRef = useRef(true)
const dispatch = (msg: any) => { const dispatch = (msg: any) => {
if (!msg) return if (!msg) return;
if (msg.type === 'round_start') { onRoundStart?.(); return }
if (msg.type === 'round_end') { onRoundEnd?.(); return } if (msg.type === 'round_start') { onRoundStart?.(); return; }
if (msg.type === 'round_end') { onRoundEnd?.(); return; }
if (msg.type === 'tick') { if (msg.type === 'tick') {
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase()) if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase());
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {})) if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}));
const g = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles const g = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles;
if (g) onGrenades?.(g) if (g) onGrenades?.(g);
if (msg.bomb) onBomb?.(msg.bomb) if (msg.bomb) onBomb?.(msg.bomb);
onPlayersAll?.(msg) onPlayersAll?.(msg);
return return;
} }
// non-tick: // --- non-tick messages (hello, map, bomb_* events, etc.) ---
const g2 = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles
if (g2 && msg.type !== 'tick') onGrenades?.(g2)
if (msg.map && typeof msg.map.name === 'string') onMap?.(msg.map.name.toLowerCase()) // Map kann als String ODER als Objekt kommen
if (msg.allplayers) onPlayersAll?.(msg) if (typeof msg.map === 'string') {
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg) onMap?.(msg.map.toLowerCase());
if (msg.grenades && msg.type !== 'tick') onGrenades?.(msg.grenades) } else if (msg.map && typeof msg.map.name === 'string') {
onMap?.(msg.map.name.toLowerCase());
const t = String(msg.type || '').toLowerCase()
if (msg.bomb || msg.c4 || t.startsWith('bomb_')) onBomb?.(msg)
} }
// 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(() => { useEffect(() => {
if (!url) return if (!url) return
shouldReconnectRef.current = true shouldReconnectRef.current = true

View File

@ -30,7 +30,7 @@ const UI = {
}, },
nade: { nade: {
stroke: '#111111', stroke: '#111111',
smokeFill: 'rgba(160,160,160,0.35)', smokeFill: 'rgba(120,140,160,0.45)',
fireFill: 'rgba(255,128,0,0.35)', fireFill: 'rgba(255,128,0,0.35)',
heFill: 'rgba(90,160,90,0.9)', heFill: 'rgba(90,160,90,0.9)',
flashFill: 'rgba(255,255,255,0.95)', flashFill: 'rgba(255,255,255,0.95)',
@ -73,6 +73,12 @@ const steamIdOf = (src: any): string | null => {
return 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) { function contrastStroke(hex: string) {
const h = hex.replace('#','') const h = hex.replace('#','')
const r = parseInt(h.slice(0,2),16)/255 const r = parseInt(h.slice(0,2),16)/255
@ -199,6 +205,9 @@ type Grenade = {
headingRad?: number | null // Rotation fürs Icon (aus velocity) headingRad?: number | null // Rotation fürs Icon (aus velocity)
spawnedAt?: number | null // für kurze Explosion-Animation spawnedAt?: number | null // für kurze Explosion-Animation
ownerId?: string | null // <- NEU: Werfer (SteamID) 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 } type DeathMarker = { id: string; sid?: string | null; x: number; y: number; t: number }
@ -236,6 +245,7 @@ export default function LiveRadar() {
// Deaths // Deaths
const deathSeqRef = useRef(0) const deathSeqRef = useRef(0)
const deathSeenRef = useRef<Set<string>>(new Set()) const deathSeenRef = useRef<Set<string>>(new Set())
const lastAlivePosRef = useRef<Map<string, {x:number,y:number}>>(new Map())
// Grenaden + Trails // Grenaden + Trails
const grenadesRef = useRef<Map<string, Grenade>>(new Map()) const grenadesRef = useRef<Map<string, Grenade>>(new Map())
@ -252,7 +262,7 @@ export default function LiveRadar() {
const [bomb, setBomb] = useState<BombState | null>(null) const [bomb, setBomb] = useState<BombState | null>(null)
// Avatare: Store (lädt /api/user/[steamId]) // 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 avatarVersion = useAvatarDirectoryStore(s => s.version) // Re-Render wenn Avatare kommen
const avatarById = useAvatarDirectoryStore(s => s.byId) const avatarById = useAvatarDirectoryStore(s => s.byId)
@ -263,8 +273,9 @@ export default function LiveRadar() {
// Spieler-IDs → Avatare laden (Store dedupliziert/limitiert) // Spieler-IDs → Avatare laden (Store dedupliziert/limitiert)
useEffect(() => { useEffect(() => {
if (players.length) ensureAvatars(players.map(p => p.id)) // p.id = SteamID const ids = [teamIdT, teamIdCT].filter(Boolean) as string[]
}, [players, ensureAvatars]) if (ids.length) ensureTeamsLoaded(ids) // preload beide Teams
}, [teamIdT, teamIdCT, ensureTeamsLoaded])
// Map-Key aus Telemetry übernehmen // Map-Key aus Telemetry übernehmen
const mapKeyFromTelemetry = useTelemetryStore(s => s.mapKey) const mapKeyFromTelemetry = useTelemetryStore(s => s.mapKey)
@ -300,6 +311,13 @@ export default function LiveRadar() {
by: null, hasKit: false, endsAt: null 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" // Kleiner Ticker, damit die Anzeigen "laufen"
const [, forceTick] = useState(0) const [, forceTick] = useState(0)
useEffect(() => { useEffect(() => {
@ -334,8 +352,14 @@ export default function LiveRadar() {
deathSeenRef.current.clear() deathSeenRef.current.clear()
trailsRef.current.clear() trailsRef.current.clear()
grenadesRef.current.clear() grenadesRef.current.clear()
lastAlivePosRef.current.clear()
bombRef.current = null bombRef.current = null
// 👇 Projektil-ID-Cache säubern
projectileIdCache.clear()
projectileIdReverse.clear()
projectileSeq = 0
if (hard) { if (hard) {
playersRef.current.clear() playersRef.current.clear()
} else if (resetPlayers) { } else if (resetPlayers) {
@ -346,6 +370,7 @@ export default function LiveRadar() {
scheduleFlush() scheduleFlush()
} }
useEffect(() => { useEffect(() => {
if (activeMapKey) clearRoundArtifacts(true, true) if (activeMapKey) clearRoundArtifacts(true, true)
// eslint-disable-next-line react-hooks/exhaustive-deps // 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) 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, { playersRef.current.set(id, {
id, id,
name: e.name ?? old?.name ?? null, name: e.name ?? old?.name ?? null,
@ -466,6 +500,12 @@ export default function LiveRadar() {
const handlePlayersAll = (msg: any) => { const handlePlayersAll = (msg: any) => {
// --- Rundenphase & Ende (läuft IMMER, auch wenn keine Player-Daten) --- // --- Rundenphase & Ende (läuft IMMER, auch wenn keine Player-Daten) ---
const pcd = msg?.phase ?? msg?.phase_countdowns 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) { if (pcd?.phase_ends_in != null) {
const sec = Number(pcd.phase_ends_in) const sec = Number(pcd.phase_ends_in)
if (Number.isFinite(sec)) { if (Number.isFinite(sec)) {
@ -541,7 +581,7 @@ export default function LiveRadar() {
} }
if (total > 0 && aliveCount === total && 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() clearRoundArtifacts()
} }
@ -582,149 +622,366 @@ export default function LiveRadar() {
setScore({ ct, t, round: rnd }) setScore({ ct, t, round: rnd })
} catch {} } 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() scheduleFlush()
} }
const normalizeGrenades = (raw: any): Grenade[] => { // ── Modul-Scope: stabile IDs für Projektile ─────────────────────────
const out: Grenade[] = [] const projectileIdCache = new Map<string, string>(); // key -> id
const now = Date.now() const projectileIdReverse = new Map<string, string>(); // id -> key
let projectileSeq = 0;
const pickTeam = (t: any): 'T' | 'CT' | string | null => { // ── Normalizer ──────────────────────────────────────────────────────
const s = mapTeam(t) function normalizeGrenades(raw: any): Grenade[] {
return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? s : null) 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 defaultRadius = (kind: Grenade['kind']) =>
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 =
kind === 'smoke' ? 150 : kind === 'smoke' ? 150 :
(kind === 'molotov' || kind === 'incendiary') ? 120 : (kind === 'molotov' || kind === 'incendiary') ? 120 :
kind === 'he' ? 280 : // für visuellen Burst kind === 'he' ? 280 :
kind === 'flash' ? 36 : kind === 'flash' ? 36 :
kind === 'decoy' ? 80 : 60 kind === 'decoy' ? 80 : 60;
return { // Owner/SteamID robust lesen (gleiche Logik wie sonst im Code)
id: String(g?.id ?? g?.entityid ?? g?.entindex ?? `${kind}#${asNum(xyz?.x)}:${asNum(xyz?.y)}:${asNum(xyz?.z)}:${phase}`), const steamIdOf = (src: any): string | null => {
kind, const raw = src?.steamId ?? src?.steam_id ?? src?.steamid ?? src?.id ?? src?.entityId ?? src?.entindex ?? src?.userid;
x: asNum(xyz?.x), y: asNum(xyz?.y), z: asNum(xyz?.z), const s = raw != null ? String(raw) : '';
radius: Number.isFinite(Number(g?.radius)) ? Number(g?.radius) : defR, if (/^\d{17}$/.test(s)) return s;
expiresAt: Number.isFinite(Number(g?.expiresAt)) ? Number(g?.expiresAt) : null, const name = (src?.name ?? src?.playerName ?? '').toString().trim();
team: pickTeam(g?.team ?? g?.owner_team ?? g?.side ?? null), if (name) return `BOT:${name}`;
phase, if (s && s !== '0' && s.toUpperCase() !== 'BOT') return s;
headingRad, return null;
spawnedAt: now, };
ownerId,
// ---- 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) // Team
const projLists = raw?.projectiles ?? raw?.grenadeProjectiles ?? raw?.nades ?? raw?.flying const teamRaw = (g?.team ?? g?.owner_team ?? g?.side ?? g?.teamnum ?? g?.team_num ?? '').toString().toUpperCase();
if (projLists) { const team = teamRaw === 'T' || teamRaw === 'CT' ? teamRaw : null;
const arr = Array.isArray(projLists) ? projLists : Object.values(projLists)
for (const g of arr) { // ── STABILE ID für Projektile ──────────────────────────────────
const k = String(g?.type ?? g?.kind ?? g?.weapon ?? 'unknown').toLowerCase() const givenId = g?.id ?? g?.entityid ?? g?.entindex;
const kind = let id: string;
k.includes('smoke') ? 'smoke' : let cacheKey: string | null = null;
(k.includes('molotov') || k.includes('incendiary') || k.includes('fire')) ? (k.includes('incendiary') ? 'incendiary' : 'molotov') :
k.includes('flash') ? 'flash' : if (givenId != null) {
k.includes('decoy') ? 'decoy' : id = String(givenId); // Engine-ID ist stabil
(k.includes('he') || k.includes('frag')) ? 'he' : 'unknown' } else if (phase === 'projectile') {
out.push(make(g, kind, '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 ret: Grenade & { _cacheKey?: string } = {
const buckets: Record<string, 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'], smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'],
molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'], molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'firebomb'],
he: ['he', 'hegrenade', 'hegrenades', 'explosive'], he: ['he', 'hegrenade', 'hegrenades', 'explosive'],
flash: ['flash', 'flashbang', 'flashbangs'], flash: ['flash', 'flashbang', 'flashbangs'],
decoy: ['decoy', 'decoys'], decoy: ['decoy', 'decoys'],
incendiary: ['incendiary', 'incgrenade'] // falls getrennt geliefert incendiary: ['incendiary', 'incgrenade'],
} unknown: []
};
const pushEffects = (kind: Grenade['kind'], list:any) => { const pushEffects = (kind: Grenade['kind'], list: any) => {
const arr = Array.isArray(list) ? list : Object.values(list) 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')) 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 [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) // ---- 3) Gemischtes Array -----------------------------------------
if (Array.isArray(raw)) { if (Array.isArray(raw) && out.length === 0) {
for (const g of raw) { for (const g of raw) {
const k = String(g?.type ?? g?.kind ?? 'unknown').toLowerCase() const hint = String(g?.type ?? g?.kind ?? g?.weapon ?? 'unknown');
const isEffect = (g?.expiresAt != null) || (g?.state && String(g.state).toLowerCase() !== 'projectile') const isEffect = (g?.expiresAt != null) || (g?.state && String(g.state).toLowerCase() !== 'projectile');
const phase: Grenade['phase'] = const phase: Grenade['phase'] =
k.includes('he') && (g?.exploded || g?.state === 'exploded') ? 'exploded' : toKind(hint) === 'he' && (g?.exploded || String(g?.state ?? '').toLowerCase() === 'exploded')
isEffect ? 'effect' : 'projectile' ? 'exploded'
const kind = : (isEffect ? 'effect' : 'projectile');
k.includes('smoke') ? 'smoke' : out.push(makeFromItem(g, hint, phase));
(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))
} }
} }
return out return out;
} }
const handleGrenades = (g: any) => { 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) : [] // ---- Trails nur für eigene fliegende Nades -------------------------
const mine = mySteamId
const seen = new Set<string>() ? list.filter(n => n.ownerId === mySteamId && n.phase === 'projectile')
const now = Date.now() : [];
const seenTrailIds = new Set<string>();
for (const it of mine) { for (const it of mine) {
seen.add(it.id) seenTrailIds.add(it.id);
const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 } const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 };
const last = prev.pts[prev.pts.length - 1] const last = prev.pts[prev.pts.length - 1];
if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) { if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) {
prev.pts.push({ x: it.x, y: it.y }) prev.pts.push({ x: it.x, y: it.y });
if (prev.pts.length > UI.trail.maxPoints) prev.pts = prev.pts.slice(-UI.trail.maxPoints) if (prev.pts.length > UI.trail.maxPoints) prev.pts = prev.pts.slice(-UI.trail.maxPoints);
} }
prev.kind = it.kind prev.kind = it.kind;
prev.lastSeen = now prev.lastSeen = now;
trailsRef.current.set(it.id, prev) trailsRef.current.set(it.id, prev);
} }
for (const [id, tr] of trailsRef.current) { 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>() // ---- Sanftes Mergen + Aufräumen -----------------------------------
for (const it of mine) next.set(it.id, it) const GRACE_PROJECTILE_MS = 0; // Schonfrist, falls ein Tick fehlt
grenadesRef.current = next 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(() => { useEffect(() => {
if (!playersRef.current && !grenadesRef.current) return if (!playersRef.current && !grenadesRef.current) return
scheduleFlush() 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 ───────── */ /* ───────── Render ───────── */
return ( return (
<div className="h-full min-h-0 w-full flex flex-col overflow-hidden"> <div className="h-full min-h-0 w-full flex flex-col overflow-hidden">
@ -1002,6 +1281,12 @@ export default function LiveRadar() {
deathMarkersRef.current = [] deathMarkersRef.current = []
trailsRef.current.clear() trailsRef.current.clear()
grenadesRef.current.clear() grenadesRef.current.clear()
// 👇 auch hier aufräumen
projectileIdCache.clear()
projectileIdReverse.clear()
projectileSeq = 0
scheduleFlush() scheduleFlush()
}} }}
@ -1038,6 +1323,7 @@ export default function LiveRadar() {
{myTeam !== 'CT' && ( {myTeam !== 'CT' && (
<TeamSidebar <TeamSidebar
team="T" team="T"
teamId={teamIdT}
players={players players={players
.filter(p => p.team === 'T' && (!myTeam || p.team === myTeam)) .filter(p => p.team === 'T' && (!myTeam || p.team === myTeam))
.map(p => ({ .map(p => ({
@ -1147,6 +1433,34 @@ export default function LiveRadar() {
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" 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 */}
{trails.map(tr => { {trails.map(tr => {
const pts = tr.pts.map(p => { const pts = tr.pts.map(p => {
@ -1169,7 +1483,7 @@ export default function LiveRadar() {
{/* Grenades: Projectiles + Effekte */} {/* Grenades: Projectiles + Effekte */}
{grenades {grenades
.filter(g => !mySteamId || g.ownerId === mySteamId) // <- NEU: nur eigene //.filter(shouldShowGrenade)
.map((g) => { .map((g) => {
const P = worldToPx(g.x, g.y) const P = worldToPx(g.x, g.y)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
@ -1182,11 +1496,11 @@ export default function LiveRadar() {
// 1) Projektil-Icon // 1) Projektil-Icon
if (g.phase === 'projectile') { if (g.phase === 'projectile') {
const size = Math.max(16, rPx * 0.7) const size = Math.max(18, 22); // fix/klein, statt radius-basiert (optional)
const href = EQUIP_ICON[g.kind] ?? EQUIP_ICON.unknown const href = EQUIP_ICON[g.kind] ?? EQUIP_ICON.unknown;
const rot = (g.headingRad ?? 0) * 180 / Math.PI const rotDeg = Number.isFinite(g.headingRad as number) ? (g.headingRad! * 180 / Math.PI) : 0;
return ( 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 <image
href={href} href={href}
x={P.x - size/2} x={P.x - size/2}
@ -1196,9 +1510,10 @@ export default function LiveRadar() {
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
/> />
</g> </g>
) );
} }
// 2) HE-Explosion // 2) HE-Explosion
if (g.kind === 'he' && g.phase === 'exploded') { if (g.kind === 'he' && g.phase === 'exploded') {
const base = Math.max(18, unitsToPx(22)) const base = Math.max(18, unitsToPx(22))
@ -1217,11 +1532,66 @@ export default function LiveRadar() {
// 3) Statische Effekte // 3) Statische Effekte
if (g.kind === 'smoke') { 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') { 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') { 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" /> 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) // Avatar-URL (mit Fallback)
const entry = avatarById[p.id] as any const entry = avatarById[p.id] as any
const avatarFromStore = entry && !entry?.notFound && entry?.avatar ? entry.avatar : null 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 // ➜ Avatare größer skalieren
const isAvatar = !!avatarUrl const isAvatar = !!avatarUrl
@ -1342,6 +1714,16 @@ export default function LiveRadar() {
const ringColor = (isAvatar && p.hasBomb) ? UI.player.bombStroke : fillColor 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 ( return (
<g key={p.id}> <g key={p.id}>
{isAvatar ? ( {isAvatar ? (
@ -1352,14 +1734,22 @@ export default function LiveRadar() {
</clipPath> </clipPath>
</defs> </defs>
<circle
cx={A.x}
cy={A.y}
r={r}
fill={baseBgColor}
opacity={baseBgOpacity}
/>
<image <image
href={String(avatarUrl)} href={String(avatarUrl)}
x={A.x - r} x={imgX}
y={A.y - r} y={imgY}
width={r * 2} width={imgW}
height={r * 2} height={imgH}
clipPath={`url(#${clipId})`} clipPath={`url(#${clipId})`}
preserveAspectRatio="xMidYMid slice" preserveAspectRatio={isBotAvatar ? 'xMidYMid meet' : 'xMidYMid slice'}
crossOrigin="anonymous" crossOrigin="anonymous"
onError={(e) => { onError={(e) => {
const img = e.currentTarget as SVGImageElement const img = e.currentTarget as SVGImageElement
@ -1501,6 +1891,7 @@ export default function LiveRadar() {
<TeamSidebar <TeamSidebar
team="CT" team="CT"
align="right" align="right"
teamId={teamIdCT}
players={players players={players
.filter(p => p.team === 'CT' && (!myTeam || p.team === myTeam)) .filter(p => p.team === 'CT' && (!myTeam || p.team === myTeam))
.map(p => ({ .map(p => ({
@ -1533,6 +1924,26 @@ export default function LiveRadar() {
0% { transform: scale(1); opacity: .85; } 0% { transform: scale(1); opacity: .85; }
100% { transform: scale(3.4); opacity: 0; } 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> `}</style>
</div> </div>
) )

View File

@ -1,6 +1,6 @@
// /src/app/radar/TeamSidebar.tsx // /src/app/radar/TeamSidebar.tsx
'use client' 'use client'
import React, { useEffect } from 'react' import React, { useEffect, useState } from 'react'
import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore' import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore'
export type Team = 'T' | 'CT' export type Team = 'T' | 'CT'
@ -17,33 +17,65 @@ export type SidebarPlayer = {
export default function TeamSidebar({ export default function TeamSidebar({
team, team,
teamId,
players, players,
align = 'left', align = 'left',
onHoverPlayer, onHoverPlayer,
}: { }: {
team: Team team: Team
teamId?: string
players: SidebarPlayer[] players: SidebarPlayer[]
align?: 'left' | 'right' align?: 'left' | 'right'
onHoverPlayer?: (id: string | null) => void onHoverPlayer?: (id: string | null) => void
}) { }) {
// Avatar-Directory // ---- NEU: Team-Info (Logo) laden ----
const ensureAvatars = useAvatarDirectoryStore(s => s.ensureLoaded) const [teamLogo, setTeamLogo] = useState<string | null>(null)
const avatarById = useAvatarDirectoryStore(s => s.byId) const [teamApiName, setTeamApiName] = useState<string | null>(null)
const avatarVer = useAvatarDirectoryStore(s => s.version) // re-render trigger 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(() => { useEffect(() => {
if (players.length) ensureAvatars(players.map(p => p.id)) let abort = false
}, [players, ensureAvatars]) 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 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 ringColor = team === 'CT' ? 'ring-blue-500' : 'ring-amber-500'
const isRight = align === 'right' 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 sorted = [...players].sort((a, b) => {
const al = (b.alive ? 1 : 0) - (a.alive ? 1 : 0) const al = (b.alive ? 1 : 0) - (a.alive ? 1 : 0)
if (al !== 0) return al if (al !== 0) return al
@ -54,26 +86,33 @@ export default function TeamSidebar({
return ( 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"> <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"> <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> <span className="tabular-nums">{aliveCount}/{players.length}</span>
</div> </div>
{/* ... Rest der Komponente bleibt unverändert ... */}
<div className="mt-2 flex-1 overflow-auto space-y-3 pr-1"> <div className="mt-2 flex-1 overflow-auto space-y-3 pr-1">
{sorted.map(p => { {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 hp = clamp(p.alive === false ? 0 : p.hp ?? 100, 0, 100)
const armor = clamp(p.armor ?? 0, 0, 100) const armor = clamp(p.armor ?? 0, 0, 100)
const dead = p.alive === false || hp <= 0 const dead = p.alive === false || hp <= 0
// Avatar aus Store (Fallback: Default)
const entry = avatarById[p.id] as any const entry = avatarById[p.id] as any
const avatarUrl = 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 ? entry.avatar
: '/assets/img/avatars/default_steam_avatar.jpg' : '/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 rowDir = isRight ? 'flex-row-reverse text-right' : 'flex-row'
const stackAlg = isRight ? 'items-end' : 'items-start' const stackAlg = isRight ? 'items-end' : 'items-start'
@ -83,26 +122,21 @@ export default function TeamSidebar({
id={`player-${p.id}`} id={`player-${p.id}`}
onMouseEnter={() => onHoverPlayer?.(p.id)} onMouseEnter={() => onHoverPlayer?.(p.id)}
onMouseLeave={() => onHoverPlayer?.(null)} 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 className={`rounded-md px-2 py-2 transition cursor-pointer
bg-neutral-800/40 hover:bg-neutral-700/40 bg-neutral-800/40 hover:bg-neutral-700/40
hover:ring-2 hover:ring-white/20 hover:ring-2 hover:ring-white/20
${dead ? 'opacity-60' : ''}`} ${dead ? 'opacity-60' : ''}`}
> >
<div className={`flex ${rowDir} items-center gap-3`}> <div className={`flex ${rowDir} items-center gap-3`}>
{/* Avatar groß + Team-Ring */}
<img <img
src={avatarUrl} src={avatarUrl}
alt={p.name || p.id} 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} width={48}
height={48} height={48}
loading="lazy" loading="lazy"
/> />
{/* Stack: Name + Icons, darunter die größeren Balken */}
<div className={`flex-1 min-w-0 flex flex-col ${stackAlg}`}> <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`}> <div className={`flex ${isRight ? 'flex-row-reverse' : ''} items-center gap-2 w-full`}>
<span className="truncate font-medium">{p.name || p.id}</span> <span className="truncate font-medium">{p.name || p.id}</span>
{p.hasBomb && team === 'T' && <span title="Bomb" className="text-red-400">💣</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>} {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> <span className={`${isRight ? 'mr-auto' : 'ml-auto'} text-xs tabular-nums`}>{hp}</span>
</div> </div>
{/* Größere Balken */}
<div className="mt-1 w-full"> <div className="mt-1 w-full">
{/* HP */}
<div className="h-2.5 rounded bg-neutral-700/60 overflow-hidden"> <div className="h-2.5 rounded bg-neutral-700/60 overflow-hidden">
<div className="h-full bg-green-500" style={{ width: `${hp}%` }} /> <div className="h-full bg-green-500" style={{ width: `${hp}%` }} />
</div> </div>
{/* Armor */}
<div className="mt-1 h-1.5 rounded bg-neutral-700/60 overflow-hidden"> <div className="mt-1 h-1.5 rounded bg-neutral-700/60 overflow-hidden">
<div className={`h-full ${barArmor}`} style={{ width: `${armor}%` }} /> <div className={`h-full ${barArmor}`} style={{ width: `${armor}%` }} />
</div> </div>