updated radar
This commit is contained in:
parent
72f9fcb8f6
commit
6e4a9a77eb
@ -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
@ -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';
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/[locale]/components/radar/hooks/useBombBeep.ts
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { BombState } from '../lib/types';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/[locale]/components/radar/lib/helpers.ts
|
||||
|
||||
import { Mapper, Overview } from './types';
|
||||
|
||||
export const RAD2DEG = 180 / Math.PI;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/[locale]/components/radar/lib/types.ts
|
||||
|
||||
export type WsStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error';
|
||||
|
||||
export type PlayerState = {
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/[locale]/components/radar/lib/ui.ts
|
||||
|
||||
export const UI = {
|
||||
player: {
|
||||
minRadiusPx: 4,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user