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 { 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'
|
||||||
|
|||||||
@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user