updated radar

This commit is contained in:
Linrador 2025-10-12 21:23:53 +02:00
parent 72f9fcb8f6
commit 6e4a9a77eb
13 changed files with 1779 additions and 265 deletions

View File

@ -1,4 +1,4 @@
// /src/app/radar/GameSocket.tsx
// /src/app/[locale]/components/radar/GameSocket.tsx
'use client'
import { useEffect, useRef } from 'react'

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/radar/RadarCanvas.tsx
'use client'
import StaticEffects from './StaticEffects';
import { BOT_ICON, DEFAULT_AVATAR, EQUIP_ICON, UI } from './lib/ui';

View File

@ -1,31 +1,176 @@
// /src/app/[locale]/components/radar/RadarHeader.tsx
'use client'
import StatusDot from '../StatusDot';
import Switch from '../Switch';
import { WsStatus } from './lib/types';
import StatusDot from '../StatusDot'
import Switch from '../Switch'
import { WsStatus } from './lib/types'
import { useMemo } from 'react'
type Phase = 'freezetime'|'live'|'bomb'|'over'|'warmup'|'unknown'
export default function RadarHeader({
useAvatars, setUseAvatars, radarWsStatus,
useAvatars,
setUseAvatars,
radarWsStatus,
roundPhase,
roundSecLeft,
// 🔽 optionale, neue Props (alles optional nur anzeigen, wenn gesetzt)
mapKey,
score,
bombSecLeft,
defuseSecLeft,
defuseHasKit,
}: {
useAvatars: boolean;
setUseAvatars: (v:boolean)=>void;
radarWsStatus: WsStatus;
useAvatars: boolean
setUseAvatars: (v:boolean)=>void
radarWsStatus: WsStatus
roundPhase?: Phase
roundSecLeft?: number|null
mapKey?: string | null
score?: { ct: number; t: number; round?: number | null }
bombSecLeft?: number | null
defuseSecLeft?: number | null
defuseHasKit?: boolean
}) {
const fmt = (s?: number|null) =>
s == null ? '—:—' : `${Math.floor(s/60)}:${String(Math.max(0,s%60)).padStart(2,'0')}`
// Phase-Badge Styling
const phase = (roundPhase ?? 'unknown') as Phase
const phaseCfg = useMemo(() => {
switch (phase) {
case 'live': return { label: 'Live', cls: 'bg-emerald-600/80 text-white' }
case 'freezetime': return { label: 'Freeze', cls: 'bg-sky-600/80 text-white' }
case 'bomb': return { label: 'Bomb', cls: 'bg-red-600/85 text-white' }
case 'warmup': return { label: 'Warmup', cls: 'bg-amber-600/80 text-white' }
case 'over': return { label: 'Round over', cls: 'bg-slate-600/80 text-white' }
default: return { label: '—', cls: 'bg-neutral-700/70 text-white' }
}
}, [phase])
// Schönerer Map-Name aus "de_ancient" -> "Ancient"
const mapLabel = useMemo(() => {
if (!mapKey) return null
const raw = mapKey.replace(/^de_/, '').replace(/[_-]+/g, ' ').trim()
const withSpace = raw.replace(/(\D)(\d)/g, '$1 $2')
return withSpace.replace(/\b\w/g, c => c.toUpperCase())
}, [mapKey])
return (
<header className="mb-4 shrink-0">
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] items-center gap-2 md:gap-4">
{/* Left: Titel + Phase + (optional) Map */}
<div className="min-w-0 flex items-center gap-2 md:gap-3">
<h2 className="text-lg md:text-xl font-semibold truncate">Live Radar</h2>
<span
className={`inline-flex items-center rounded px-2 py-0.5 text-[11px] md:text-xs font-semibold uppercase ${phaseCfg.cls}`}
title={`Rundenphase: ${phase}`}
>
{phaseCfg.label}
</span>
{mapLabel && (
<span
className="hidden sm:inline-flex items-center rounded bg-black/30 text-white/90 text-[11px] md:text-xs px-2 py-0.5"
title="Map"
>
{mapLabel}
</span>
)}
</div>
{/* Center: Avatar/Icons Toggle */}
<div className="flex justify-center">
<Switch
id="radar-avatar-toggle"
checked={useAvatars}
onChange={setUseAvatars}
labelLeft="Icons"
labelRight="Avatare"
className="mx-auto"
/>
</div>
{/* Right: Score (optional) + Timer-Chips + WS-Status */}
<div className="flex items-center justify-start md:justify-end gap-2 md:gap-3">
{/* Score-Pill (optional) */}
{score && (
<div className="hidden sm:inline-flex items-center gap-2 rounded bg-black/40 text-white text-[11px] md:text-xs font-semibold px-2 py-0.5">
<span className="inline-flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-blue-500" />
CT <span className="tabular-nums">{score.ct}</span>
</span>
<span className="opacity-60">:</span>
<span className="inline-flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500" />
T <span className="tabular-nums">{score.t}</span>
</span>
{Number.isFinite(Number(score.round)) && (
<span className="ml-1 opacity-70 font-normal">R{Number(score.round)}</span>
)}
</div>
)}
{/* Round-Timer */}
<TimerChip
title="Rundenzeit"
icon="⏱"
value={fmt(roundSecLeft)}
className="bg-neutral-800 text-white/90"
/>
{/* Bomben-Timer (optional) */}
{bombSecLeft != null && (
<TimerChip
title="C4"
icon="💣"
value={fmt(bombSecLeft)}
className="bg-red-600/80 text-white"
/>
)}
{/* Defuse-Timer (optional) */}
{defuseSecLeft != null && (
<TimerChip
title={defuseHasKit ? 'Defuse (Kit)' : 'Defuse'}
icon="🛠️"
value={fmt(defuseSecLeft)}
className="bg-blue-600/80 text-white"
/>
)}
<StatusDot status={radarWsStatus} label="Positionsdaten" />
</div>
</div>
</header>
)
}
/* ───────────────── helpers ───────────────── */
function TimerChip({
title, icon, value, className = ''
}: {
title: string
icon: string
value: string
className?: string
}) {
return (
<div className="mb-4 shrink-0 flex items-center">
<h2 className="text-xl font-semibold flex-1">Live Radar</h2>
<div className="flex-1 flex justify-center">
<Switch
id="radar-avatar-toggle"
checked={useAvatars}
onChange={setUseAvatars}
labelLeft="Icons"
labelRight="Avatare"
className="mx-auto"
/>
</div>
<div className="flex-1 flex items-center justify-end gap-4">
<StatusDot status={radarWsStatus} label="Positionsdaten" />
</div>
</div>
);
<span
className={[
'inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] md:text-xs font-mono',
'ring-1 ring-black/10',
className
].join(' ')}
title={title}
aria-label={`${title}: ${value}`}
>
<span aria-hidden className="font-sans">{icon}</span>
<span className="tabular-nums">{value}</span>
</span>
)
}

View File

@ -1,7 +1,8 @@
// /src/app/components/radar/StaticEffects.tsx
// /src/app/[locale]/components/radar/StaticEffects.tsx
'use client'
import React from 'react'
import { GRENADE_LIFE_MS, SMOKE_LINGER_MS } from './lib/grenades';
type BombState = {
x: number
@ -84,9 +85,9 @@ export default function StaticEffects({
const P = worldToPx(g.x, g.y)
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
// Lebenszeiten robust bestimmen
const DEFAULT_LIFE = 18_000
const leftMs = (typeof g.lifeLeftMs === 'number')
// Lebenszeiten robust bestimmen (Server-/Fallbacks nur noch sekundär für Opacity-Fallback)
const DEFAULT_LIFE = GRENADE_LIFE_MS.smoke + SMOKE_LINGER_MS // 23s
const leftMs = (typeof g.lifeLeftMs === 'number')
? Math.max(0, g.lifeLeftMs)
: (g.expiresAt ? Math.max(0, g.expiresAt - Date.now()) : null)
@ -94,52 +95,74 @@ export default function StaticEffects({
? Math.max(0, g.lifeElapsedMs)
: (typeof g.effectTimeSec === 'number' ? Math.max(0, g.effectTimeSec * 1000) : null)
const totalMs = (leftMs != null && elapsedMs != null)
? Math.max(1500, leftMs + elapsedMs) // nie zu kurz
const totalMs = (leftMs != null && elapsedMs != null)
? Math.max(1500, leftMs + elapsedMs)
: DEFAULT_LIFE
const pRaw = (elapsedMs != null) ? (elapsedMs / totalMs)
: (leftMs != null) ? (1 - Math.min(1, leftMs / totalMs))
: 0
const p = Math.max(0, Math.min(1, pRaw)) // clamp
// Easing
const easeOutCubic = (t:number) => 1 - Math.pow(1 - t, 3)
const easeInQuad = (t:number) => t * t
// Phasen: früh reinzoomen, spät zusammenschrumpfen
const ZOOM_IN_MS = 600; // Dauer fürs Reinzoomen
const COLLAPSE_MS = 800; // Dauer fürs Zusammenfallen
const SCALE_IN_START = 0.68;
const SCALE_OUT_END = 0.65;
const ZOOM_IN_MS = 600
const COLLAPSE_MS = 800
const SCALE_IN_START = 0.68
const SCALE_OUT_END = 0.65
let scale = 1;
let phaseAlpha = 1;
// ── TIMER: Immer lokal bei 20s starten, unabhängig von Serverzeiten
const DISPLAY_TIMER_MS = 20_000
const firstSeenAt = (g as any).firstSeenAt ?? g.spawnedAt ?? Date.now()
const timerLeftMs = Math.max(0, DISPLAY_TIMER_MS - (Date.now() - firstSeenAt))
const timerSecs = Math.ceil(timerLeftMs / 1000)
const timerAlpha = Math.min(1, timerLeftMs / 1000) // in letzter Sekunde ausblenden
const fontSize = Math.max(12, (Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60)) * 0.78) * 0.45)
// ── Scale/Alpha-Phasen: an TIMER koppeln (Fallback: leftMs)
let scale = 1
let phaseAlpha = 1
if (elapsedMs != null && elapsedMs < ZOOM_IN_MS) {
// frühe Phase: innen -> außen
const t = Math.max(0, Math.min(1, elapsedMs / ZOOM_IN_MS));
const e = easeOutCubic(t);
scale = SCALE_IN_START + (1 - SCALE_IN_START) * e;
phaseAlpha = e;
} else if (leftMs != null && leftMs <= COLLAPSE_MS) {
// Endphase: außen -> innen
const t = Math.max(0, Math.min(1, 1 - (leftMs / COLLAPSE_MS)));
const e = easeInQuad(t);
scale = 1 - (1 - SCALE_OUT_END) * e;
phaseAlpha = 1 - e;
const t = Math.max(0, Math.min(1, elapsedMs / ZOOM_IN_MS))
const e = easeOutCubic(t)
scale = SCALE_IN_START + (1 - SCALE_IN_START) * e
phaseAlpha = e
} else {
const collapseLeft =
Number.isFinite(timerLeftMs) ? timerLeftMs :
(leftMs != null ? leftMs : null)
if (collapseLeft != null && collapseLeft <= COLLAPSE_MS) {
const t = Math.max(0, Math.min(1, 1 - (collapseLeft / COLLAPSE_MS)))
const e = easeInQuad(t)
scale = 1 - (1 - SCALE_OUT_END) * e
phaseAlpha = 1 - e
}
}
// ── Opacity: bis kurz vor Schluss stabil, erst am Ende leicht absenken
// Steuere ebenfalls über den Anzeige-Timer; Fallback auf leftMs/totalMs.
let baseOpacity = 1
const SAFE_MS = 2000 // in den letzten 2s leicht herunterblenden auf ~0.8
const collapseLeftForOpacity =
Number.isFinite(timerLeftMs) ? timerLeftMs :
(leftMs != null ? leftMs : null)
if (collapseLeftForOpacity != null) {
const lin = Math.max(0, Math.min(1, collapseLeftForOpacity / SAFE_MS))
baseOpacity = 0.8 + 0.2 * lin // 1.0 → 0.8 in den letzten 2s
} else {
// Fallback: sanft 1.0 → 0.75 über die gesamte Lebenszeit
const lifeMs = totalMs
const fracLeft = leftMs == null ? 1 : Math.min(1, leftMs / lifeMs)
baseOpacity = 0.75 + 0.25 * fracLeft
}
// bisherige sanfte Gesamt-Opacity (verbleibende Lebenszeit)
const lifeMs = totalMs
const fracLeft = leftMs == null ? 1 : Math.min(1, leftMs / lifeMs)
const baseOpacity = 0.75 + 0.25 * (1 - (1 - fracLeft)) // 0.75..1.0
const overallOpacity = baseOpacity * phaseAlpha
// Farbe (oder nimm ui.nade.smokeFill)
const fill = '#9bd4ff'
// kompaktere Form + runder oben (wie bei dir zuletzt)
// kompaktere Form
const R = rPx * 0.78
const lobes = [
{ x: -R * 0.68, y: R * 0.14, r: R * 0.58, anim: '' },
@ -156,9 +179,9 @@ export default function StaticEffects({
return (
<g
key={g.id}
transform={`translate(${P.x}, ${P.y}) scale(${scale})`} // <- Zoom über Zeit
transform={`translate(${P.x}, ${P.y}) scale(${scale})`}
opacity={overallOpacity}
style={{ transformBox: 'fill-box', transformOrigin: 'center' }}
style={{ transformBox: 'fill-box', transformOrigin: 'center', pointerEvents: 'none' }}
>
{lobes.map((l, i) => (
<circle
@ -181,12 +204,33 @@ export default function StaticEffects({
}
/>
))}
{timerSecs > 0 && (
<text
x={0}
y={R * 0.06}
textAnchor="middle"
dominantBaseline="middle"
style={{
fontSize,
fontWeight: 800,
fill: '#ffffff',
opacity: timerAlpha,
paintOrder: 'stroke',
stroke: 'rgba(0,0,0,0.75)',
strokeWidth: Math.max(2, fontSize * 0.12),
letterSpacing: '-0.02em',
userSelect: 'none',
}}
>
{timerSecs}
</text>
)}
</g>
)
}
const molotovNode = (g: Grenade) => {
const P = worldToPx(g.x, g.y)
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
@ -355,12 +399,22 @@ export default function StaticEffects({
// nur die gewünschten statischen Effekte zeichnen
const nodes = grenades
.filter(g => g.phase === 'effect' && (g.kind === 'smoke' || g.kind === 'molotov' || g.kind === 'incendiary' || g.kind === 'decoy' || g.kind === 'flash'))
.filter(g =>
g.phase === 'effect' &&
(g.kind === 'smoke' || g.kind === 'molotov' || g.kind === 'incendiary' || g.kind === 'decoy' || g.kind === 'flash')
)
.map(g => {
const P = worldToPx(g.x, g.y)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
if (g.kind === 'smoke') return smokeNode(g)
if (g.kind === 'molotov' || g.kind === 'incendiary') return molotovNode(g)
if (g.kind === 'molotov' || g.kind === 'incendiary') {
const spreaded = (g as any).spreaded === true || ((g as any).flamesCount ?? 0) > 0
if (!spreaded) return null // << Nur zeigen, wenn wirklich Flames vorhanden/spreaded
return molotovNode(g)
}
if (g.kind === 'decoy') return decoyNode(g)
if (g.kind === 'flash') return flashNode(g)
return null

View File

@ -1,4 +1,5 @@
// /src/app/radar/TeamSidebar.tsx
// /src/app/[locale]/components/radar/TeamSidebar.tsx
'use client'
import React, { useEffect, useState } from 'react'
import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore'

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/radar/hooks/useBombBeep.ts
import { useEffect, useRef, useState } from 'react';
import { BombState } from '../lib/types';

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/radar/hooks/useOverview.ts
import { useEffect, useMemo, useState } from 'react';
import { Mapper, Overview } from '../lib/types';
import { defaultWorldToPx, parseOverviewJson, parseValveKvOverview } from '../lib/helpers';

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/radar/hooks/useRadarState.ts
import { useEffect, useMemo, useRef, useState } from 'react';
import { BombState, DeathMarker, Grenade, PlayerState, Score, Trail, WsStatus } from '../lib/types';
import { UI } from '../lib/ui';

View File

@ -1,19 +1,186 @@
// /src/app/[locale]/components/radar/lib/grenades.ts
import { Grenade } from './types';
// util to identify team for filtering later
/* ───────── Laufzeiten (zentral) ───────── */
export const GRENADE_LIFE_MS = {
smoke: 21_000, // Basisdauer Smoke (Server-/Spiel-Realität)
molotov: 7_000,
incendiary: 7_000,
flash: 300,
he_projectile: 300,
he_exploded: 350,
decoy: 15_000,
fallback: 2_000,
} as const;
// Lokales „Nachglühen“ für Smokes (+2 s länger sichtbar)
export const SMOKE_LINGER_MS = 2_000;
/** Einheitliche Default-Laufzeit pro Art/Phase. */
export function defaultLifeMs(kind: Grenade['kind'], phase: Grenade['phase'] | null) {
switch (kind) {
case 'smoke': return GRENADE_LIFE_MS.smoke;
case 'molotov': return GRENADE_LIFE_MS.molotov;
case 'incendiary': return GRENADE_LIFE_MS.incendiary;
case 'flash': return GRENADE_LIFE_MS.flash;
case 'he': return phase === 'exploded' ? GRENADE_LIFE_MS.he_exploded : GRENADE_LIFE_MS.he_projectile;
case 'decoy': return GRENADE_LIFE_MS.decoy;
default: return GRENADE_LIFE_MS.fallback;
}
}
/* ───────── Normalisierung ───────── */
const KIND_MAP: Record<string, Grenade['kind']> = {
smoke: 'smoke', smokegrenade: 'smoke',
molotov: 'molotov', incendiary: 'incendiary', incgrenade: 'incendiary',
inferno: 'molotov', fire: 'molotov', firebomb: 'molotov', // häufige Synonyme
he: 'he', hegrenade: 'he', frag: 'he', explosive: 'he',
flash: 'flash', flashbang: 'flash',
decoy: 'decoy'
};
const asNum = (n: any, d = NaN) => {
const v = Number(n);
return Number.isFinite(v) ? v : d;
};
const parseVec3String = (str?: string) => {
if (!str || typeof str !== 'string') return { x: NaN, y: NaN, z: NaN };
const [x, y, z] = str.split(',').map((s) => asNum(s.trim()));
return { x, y, z };
};
const parsePos = (g: any) => {
// akzeptiert {x,y,z}, [x,y,z], "x, y, z" oder einzelne Felder
const pos = g.pos ?? g.position ?? g.location ?? g.coordinates ?? g.origin ?? [g.x, g.y, g.z];
if (Array.isArray(pos)) {
return { x: asNum(pos[0]), y: asNum(pos[1]), z: asNum(pos[2], 0) };
}
if (typeof pos === 'string') {
const v = parseVec3String(pos);
return { x: v.x, y: v.y, z: asNum(v.z, 0) };
}
if (pos && typeof pos === 'object') {
return { x: asNum(pos.x), y: asNum(pos.y), z: asNum(pos.z, 0) };
}
return { x: asNum(g.x), y: asNum(g.y), z: asNum(g.z, 0) };
};
/** Team der Granate bestimmen (fallback über Werfer). */
export function teamOfGrenade(
g: Grenade,
teamOfPlayer: (sid?: string|null)=>('T'|'CT'|string|null)
): 'T'|'CT'|string|null {
teamOfPlayer: (sid?: string | null) => 'T' | 'CT' | string | null
): 'T' | 'CT' | string | null {
if (g.team === 'T' || g.team === 'CT') return g.team;
const ownerTeam = teamOfPlayer(g.ownerId);
const ownerTeam = teamOfPlayer(g.ownerId ?? null);
return ownerTeam === 'T' || ownerTeam === 'CT' ? ownerTeam : null;
}
// --- normalizeGrenades (aus deiner Datei extrahiert & unverändert in der Logik) ---
/** Liefert eine normalisierte Liste von Grenades. */
export function normalizeGrenades(raw: any): Grenade[] {
// 👉 Hier bitte deinen bestehenden, langen Normalizer einfügen.
// Ich habe ihn aus Platzgründen nicht nochmal 1:1 kopiert.
// Du kannst den Block aus deiner LiveRadar.tsx übernehmen und hier exportieren.
return [];
const arr = Array.isArray(raw) ? raw : Object.values(raw ?? {});
const out: Grenade[] = [];
for (const g of arr) {
// Kind
const kindRaw = String(g.kind ?? g.type ?? g.weapon ?? g.name ?? g.nade ?? 'unknown').toLowerCase();
const kind: Grenade['kind'] = KIND_MAP[kindRaw] ?? 'unknown';
// Position
const { x, y, z } = parsePos(g);
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
// Phase
const phaseRaw = String(g.phase ?? g.state ?? g.status ?? '').toLowerCase();
const hasEffectHints = typeof g.effectTimeSec === 'number' || typeof g.lifeElapsedMs === 'number' || typeof g.expiresAt === 'number';
let phase: Grenade['phase'] =
phaseRaw.includes('effect') || hasEffectHints ? 'effect'
: phaseRaw.includes('explode') ? 'exploded'
: 'projectile';
// Heading (aus velocity/forward)
let headingRad: number | null = null;
const vel = g.vel ?? g.velocity ?? g.dir ?? g.forward;
if (vel && Number.isFinite(vel.x) && Number.isFinite(vel.y)) {
headingRad = Math.atan2(Number(vel.y), Number(vel.x));
} else if (Number.isFinite(g.headingRad)) {
headingRad = Number(g.headingRad);
}
// ID (stabil genug; Engine-ID bevorzugen)
const id = String(g.id ?? g.entityid ?? g.entindex ?? `${kind}#${Math.round(x)}:${Math.round(y)}:${Math.round(z)}`);
// Meta
const team = g.team === 'T' || g.team === 'CT' ? g.team : null;
const radius = Number.isFinite(Number(g.radius)) ? Number(g.radius) : null;
const spawnedAt = Number.isFinite(Number(g.spawnedAt ?? g.t)) ? Number(g.spawnedAt ?? g.t) : Date.now();
const ownerId = g.ownerId ?? g.owner ?? g.thrower ?? g.player ?? g.userid ?? null;
// Zeit-/Effektfelder vom Server (optional)
let effectTimeSec = typeof g.effectTimeSec === 'number' ? g.effectTimeSec : undefined;
let lifeElapsedMs = typeof g.lifeElapsedMs === 'number' ? g.lifeElapsedMs : undefined;
let lifeLeftMs = typeof g.lifeLeftMs === 'number' ? g.lifeLeftMs : undefined;
let expiresAt = typeof g.expiresAt === 'number' ? g.expiresAt : undefined;
/* Smoke lokal um +2s verlängern
Strategie:
- Wir definieren eine *lokale* Gesamtdauer = default(21s) + 2s Linger.
- Wenn der Server effectTimeSec liefert, drehen wir die Zeit zurück
(elapsed -= 2s), wodurch rechnerisch 2s mehr Restzeit bleiben.
- Andernfalls erhöhen wir die Restzeit bzw. schieben expiresAt nach hinten.
- Wir setzen expiresAt mindestens auf jetzt + lifeLeftMs, damit der
Renderer die Smoke nicht vorzeitig entfernt. */
if (kind === 'smoke' && phase === 'effect') {
const base = defaultLifeMs('smoke', 'effect'); // 21_000
const total = base + SMOKE_LINGER_MS; // 23_000
const now = Date.now();
if (typeof effectTimeSec === 'number') {
const elapsedRaw = Math.max(0, Math.round(effectTimeSec * 1000)) + SMOKE_LINGER_MS;
const elapsedAdj = Math.max(0, elapsedRaw); // „+2s länger“
const left = Math.max(0, total - elapsedAdj);
lifeElapsedMs = elapsedAdj;
lifeLeftMs = left;
effectTimeSec = elapsedAdj / 1000;
const expLocal = now + left;
expiresAt = Math.max(expiresAt ?? 0, expLocal);
} else if (typeof lifeElapsedMs === 'number') {
// Wir kennen die verstrichene Zeit → Rest = total - elapsed
const left = Math.max(0, total - lifeElapsedMs);
lifeLeftMs = Math.max(lifeLeftMs ?? 0, left);
expiresAt = Math.max(expiresAt ?? 0, now + (lifeLeftMs ?? 0));
} else if (typeof lifeLeftMs === 'number') {
// Wir kennen nur die Restzeit → +2s addieren
lifeLeftMs = Math.max(0, lifeLeftMs + SMOKE_LINGER_MS);
expiresAt = Math.max(expiresAt ?? 0, now + lifeLeftMs);
} else if (typeof expiresAt === 'number') {
// Nur expiresAt bekannt → um +2s schieben
expiresAt = expiresAt + SMOKE_LINGER_MS;
lifeLeftMs = Math.max(0, expiresAt - now);
} else {
// Keine Zeitangaben → aus Spawn + total ableiten
const exp = spawnedAt + total;
expiresAt = exp;
lifeElapsedMs = Math.max(0, now - spawnedAt);
lifeLeftMs = Math.max(0, exp - now);
effectTimeSec = lifeElapsedMs / 1000;
}
}
out.push({
id, kind, x, y, z: Number.isFinite(z) ? z : 0,
radius,
expiresAt: expiresAt ?? null,
team, phase, headingRad, spawnedAt,
ownerId: ownerId ? String(ownerId) : null,
effectTimeSec,
lifeElapsedMs,
lifeLeftMs
});
}
return out;
}

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/radar/lib/helpers.ts
import { Mapper, Overview } from './types';
export const RAD2DEG = 180 / Math.PI;

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/radar/lib/types.ts
export type WsStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error';
export type PlayerState = {

View File

@ -1,3 +1,5 @@
// /src/app/[locale]/components/radar/lib/ui.ts
export const UI = {
player: {
minRadiusPx: 4,