updated
This commit is contained in:
parent
94bbaaa37e
commit
e693af798b
10
public/assets/img/icons/ui/bot.svg
Normal file
10
public/assets/img/icons/ui/bot.svg
Normal 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 |
@ -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'
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,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<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();
|
||||
|
||||
// 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<string, string[]> = {
|
||||
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<Grenade['kind'], string[]> = {
|
||||
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 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
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
? entry.avatar
|
||||
: '/assets/img/avatars/default_steam_avatar.jpg'
|
||||
|
||||
// Layout: Avatar neben Stack(Name+Bars); rechts gespiegelt bei align="right"
|
||||
const entry = avatarById[p.id] as any
|
||||
const avatarUrl =
|
||||
isBotId(p.id) // <- Bot? dann Bot-Icon
|
||||
? BOT_ICON
|
||||
: (entry && !entry?.notFound && entry?.avatar
|
||||
? entry.avatar
|
||||
: '/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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user