From e693af798bee5723437dad48ae430146509200f9 Mon Sep 17 00:00:00 2001 From: Linrador Date: Sat, 13 Sep 2025 15:49:05 +0200 Subject: [PATCH] updated --- public/assets/img/icons/ui/bot.svg | 10 + src/app/api/team/[teamId]/route.ts | 2 +- src/app/lib/useAvatarDirectoryStore.ts | 62 ++- src/app/radar/GameSocket.tsx | 51 +- src/app/radar/LiveRadar.tsx | 653 ++++++++++++++++++++----- src/app/radar/TeamSidebar.tsx | 92 ++-- 6 files changed, 693 insertions(+), 177 deletions(-) create mode 100644 public/assets/img/icons/ui/bot.svg diff --git a/public/assets/img/icons/ui/bot.svg b/public/assets/img/icons/ui/bot.svg new file mode 100644 index 0000000..c014fe4 --- /dev/null +++ b/public/assets/img/icons/ui/bot.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/app/api/team/[teamId]/route.ts b/src/app/api/team/[teamId]/route.ts index 1898376..7013d68 100644 --- a/src/app/api/team/[teamId]/route.ts +++ b/src/app/api/team/[teamId]/route.ts @@ -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' diff --git a/src/app/lib/useAvatarDirectoryStore.ts b/src/app/lib/useAvatarDirectoryStore.ts index ef69877..ea067d3 100644 --- a/src/app/lib/useAvatarDirectoryStore.ts +++ b/src/app/lib/useAvatarDirectoryStore.ts @@ -17,7 +17,8 @@ type Store = { loading: Set version: number upsert: (u: AvatarUser | NotFound) => void - ensureLoaded: (steamIds: string[]) => Promise + ensureLoaded: (steamIds: string[]) => Promise // (bestehender Fallback) + ensureTeamsLoaded: (teamIds: string[]) => Promise // <-- NEU } const isValidSteamId = (s: string) => /^\d{17}$/.test(s) @@ -42,12 +43,13 @@ export const useAvatarDirectoryStore = create((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((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((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 } + }) + } + }, })) diff --git a/src/app/radar/GameSocket.tsx b/src/app/radar/GameSocket.tsx index ed10fc1..961cf22 100644 --- a/src/app/radar/GameSocket.tsx +++ b/src/app/radar/GameSocket.tsx @@ -23,34 +23,45 @@ 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) + // 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); + }; - const t = String(msg.type || '').toLowerCase() - if (msg.bomb || msg.c4 || t.startsWith('bomb_')) onBomb?.(msg) - } useEffect(() => { if (!url) return diff --git a/src/app/radar/LiveRadar.tsx b/src/app/radar/LiveRadar.tsx index dfc57aa..b199923 100644 --- a/src/app/radar/LiveRadar.tsx +++ b/src/app/radar/LiveRadar.tsx @@ -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>(new Set()) + const lastAlivePosRef = useRef>(new Map()) // Grenaden + Trails const grenadesRef = useRef>(new Map()) @@ -252,7 +262,7 @@ export default function LiveRadar() { const [bomb, setBomb] = useState(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,148 +622,365 @@ 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(); // key -> id + const projectileIdReverse = new Map(); // 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(); - // Helper: baue eine Grenade - const make = (g:any, kindIn:string, phase:'projectile'|'effect'|'exploded'): Grenade => { + // ---- 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'; + }; + + const defaultRadius = (kind: Grenade['kind']) => + kind === 'smoke' ? 150 : + (kind === 'molotov' || kind === 'incendiary') ? 120 : + kind === 'he' ? 280 : + kind === 'flash' ? 36 : + kind === 'decoy' ? 80 : 60; + + // 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 ?? - { steamId: g?.ownerSteamId ?? g?.steamid ?? g?.steam_id ?? g?.owner_id } + 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); - const ownerId = steamIdOf(ownerRaw) + // Art + const rawKind = String(g?.kind ?? g?.type ?? g?.weapon ?? kHint ?? 'unknown'); + const kind = toKind(rawKind); - 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 }) + // Position + const posSrc = g?.pos ?? g?.position ?? g?.location ?? g?.origin ?? g; + const { x, y, z } = parseVec3Loose(posSrc); - // 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) + // 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); - // Radius defaults für Effektphase - const defR = - kind === 'smoke' ? 150 : - (kind === 'molotov' || kind === 'incendiary') ? 120 : - kind === 'he' ? 280 : // für visuellen Burst - kind === 'flash' ? 36 : - kind === 'decoy' ? 80 : 60 + // Phase bestimmen + let phase: Grenade['phase'] = + (kind === 'he' && (g?.exploded || String(g?.state ?? '').toLowerCase() === 'exploded')) ? 'exploded' + : (phaseHint ?? ((g?.expiresAt != null) ? 'effect' : 'projectile')); - 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, + // 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); + } + } + + 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) Effekt-Listen (stehende Wolke/Feuer etc.) - const buckets: Record = { - smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'], - molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'], - he: ['he', 'hegrenade', 'hegrenades', 'explosive'], - flash: ['flash', 'flashbang', 'flashbangs'], - decoy: ['decoy', 'decoys'], - incendiary: ['incendiary', 'incgrenade'] // falls getrennt geliefert - } + // ---- 2) Buckets/Mixed: GSI-ähnliche Formate ----------------------- + const out: Grenade[] = []; - 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')) - } + 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 = { + smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'], + molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'firebomb'], + he: ['he', 'hegrenade', 'hegrenades', 'explosive'], + flash: ['flash', 'flashbang', 'flashbangs'], + decoy: ['decoy', 'decoys'], + 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) { + 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 mine = mySteamId ? list.filter(n => n.ownerId === mySteamId) : [] + const now = Date.now(); + const list = normalizeGrenades(g); // liefert ggf. ._cacheKey an Projektile - const seen = new Set() - 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(); 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() - 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(grenadesRef.current as any); + const seenIds = new Set(); - 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 @@ -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 (
@@ -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' && ( 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" > + + + + + + + + + + + + + + + {/* äußere Flamme */} + + {/* innerer, heller Kern */} + + + + + {/* 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 ( - + - ) + ); } + // 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 + 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 ( + + ) } + if (g.kind === 'molotov' || g.kind === 'incendiary') { - return + const W = Math.max(28, rPx * 1.4); + const H = W * 1.25; + + return ( + + {/* optionaler Team-Ring */} + + + {/* WICHTIG: äußere Gruppe = nur Translate */} + + {/* innere Gruppe = nur Animation */} + + + + + + ); } + if (g.kind === 'decoy') { return } @@ -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 ( {isAvatar ? ( @@ -1352,14 +1734,22 @@ export default function LiveRadar() { + + { const img = e.currentTarget as SVGImageElement @@ -1501,6 +1891,7 @@ export default function LiveRadar() { 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); } + } `}
) diff --git a/src/app/radar/TeamSidebar.tsx b/src/app/radar/TeamSidebar.tsx index 32bef48..45e8c1f 100644 --- a/src/app/radar/TeamSidebar.tsx +++ b/src/app/radar/TeamSidebar.tsx @@ -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(null) + const [teamApiName, setTeamApiName] = useState(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 (