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'
|
'use client'
|
||||||
import { useEffect, useRef } from 'react'
|
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'
|
'use client'
|
||||||
import StaticEffects from './StaticEffects';
|
import StaticEffects from './StaticEffects';
|
||||||
import { BOT_ICON, DEFAULT_AVATAR, EQUIP_ICON, UI } from './lib/ui';
|
import { BOT_ICON, DEFAULT_AVATAR, EQUIP_ICON, UI } from './lib/ui';
|
||||||
|
|||||||
@ -1,19 +1,88 @@
|
|||||||
|
// /src/app/[locale]/components/radar/RadarHeader.tsx
|
||||||
'use client'
|
'use client'
|
||||||
import StatusDot from '../StatusDot';
|
|
||||||
import Switch from '../Switch';
|
import StatusDot from '../StatusDot'
|
||||||
import { WsStatus } from './lib/types';
|
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({
|
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;
|
useAvatars: boolean
|
||||||
setUseAvatars: (v:boolean)=>void;
|
setUseAvatars: (v:boolean)=>void
|
||||||
radarWsStatus: WsStatus;
|
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 (
|
return (
|
||||||
<div className="mb-4 shrink-0 flex items-center">
|
<header className="mb-4 shrink-0">
|
||||||
<h2 className="text-xl font-semibold flex-1">Live Radar</h2>
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] items-center gap-2 md:gap-4">
|
||||||
<div className="flex-1 flex justify-center">
|
|
||||||
|
{/* 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
|
<Switch
|
||||||
id="radar-avatar-toggle"
|
id="radar-avatar-toggle"
|
||||||
checked={useAvatars}
|
checked={useAvatars}
|
||||||
@ -23,9 +92,85 @@ export default function RadarHeader({
|
|||||||
className="mx-auto"
|
className="mx-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-end gap-4">
|
|
||||||
|
{/* 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" />
|
<StatusDot status={radarWsStatus} label="Positionsdaten" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────────────── helpers ───────────────── */
|
||||||
|
|
||||||
|
function TimerChip({
|
||||||
|
title, icon, value, className = ''
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
value: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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'
|
'use client'
|
||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { GRENADE_LIFE_MS, SMOKE_LINGER_MS } from './lib/grenades';
|
||||||
|
|
||||||
type BombState = {
|
type BombState = {
|
||||||
x: number
|
x: number
|
||||||
@ -84,8 +85,8 @@ export default function StaticEffects({
|
|||||||
const P = worldToPx(g.x, g.y)
|
const P = worldToPx(g.x, g.y)
|
||||||
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
||||||
|
|
||||||
// Lebenszeiten robust bestimmen
|
// Lebenszeiten robust bestimmen (Server-/Fallbacks – nur noch sekundär für Opacity-Fallback)
|
||||||
const DEFAULT_LIFE = 18_000
|
const DEFAULT_LIFE = GRENADE_LIFE_MS.smoke + SMOKE_LINGER_MS // 23s
|
||||||
const leftMs = (typeof g.lifeLeftMs === 'number')
|
const leftMs = (typeof g.lifeLeftMs === 'number')
|
||||||
? Math.max(0, g.lifeLeftMs)
|
? Math.max(0, g.lifeLeftMs)
|
||||||
: (g.expiresAt ? Math.max(0, g.expiresAt - Date.now()) : null)
|
: (g.expiresAt ? Math.max(0, g.expiresAt - Date.now()) : null)
|
||||||
@ -95,51 +96,73 @@ export default function StaticEffects({
|
|||||||
: (typeof g.effectTimeSec === 'number' ? Math.max(0, g.effectTimeSec * 1000) : null)
|
: (typeof g.effectTimeSec === 'number' ? Math.max(0, g.effectTimeSec * 1000) : null)
|
||||||
|
|
||||||
const totalMs = (leftMs != null && elapsedMs != null)
|
const totalMs = (leftMs != null && elapsedMs != null)
|
||||||
? Math.max(1500, leftMs + elapsedMs) // nie zu kurz
|
? Math.max(1500, leftMs + elapsedMs)
|
||||||
: DEFAULT_LIFE
|
: 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
|
// Easing
|
||||||
const easeOutCubic = (t:number) => 1 - Math.pow(1 - t, 3)
|
const easeOutCubic = (t:number) => 1 - Math.pow(1 - t, 3)
|
||||||
const easeInQuad = (t:number) => t * t
|
const easeInQuad = (t:number) => t * t
|
||||||
|
|
||||||
// Phasen: früh reinzoomen, spät zusammenschrumpfen
|
// Phasen: früh reinzoomen, spät zusammenschrumpfen
|
||||||
const ZOOM_IN_MS = 600; // Dauer fürs Reinzoomen
|
const ZOOM_IN_MS = 600
|
||||||
const COLLAPSE_MS = 800; // Dauer fürs Zusammenfallen
|
const COLLAPSE_MS = 800
|
||||||
const SCALE_IN_START = 0.68;
|
const SCALE_IN_START = 0.68
|
||||||
const SCALE_OUT_END = 0.65;
|
const SCALE_OUT_END = 0.65
|
||||||
|
|
||||||
let scale = 1;
|
// ── TIMER: Immer lokal bei 20s starten, unabhängig von Serverzeiten
|
||||||
let phaseAlpha = 1;
|
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) {
|
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 t = Math.max(0, Math.min(1, elapsedMs / ZOOM_IN_MS));
|
const e = easeOutCubic(t)
|
||||||
const e = easeOutCubic(t);
|
scale = SCALE_IN_START + (1 - SCALE_IN_START) * e
|
||||||
scale = SCALE_IN_START + (1 - SCALE_IN_START) * e;
|
phaseAlpha = e
|
||||||
phaseAlpha = e;
|
} else {
|
||||||
} else if (leftMs != null && leftMs <= COLLAPSE_MS) {
|
const collapseLeft =
|
||||||
// Endphase: außen -> innen
|
Number.isFinite(timerLeftMs) ? timerLeftMs :
|
||||||
const t = Math.max(0, Math.min(1, 1 - (leftMs / COLLAPSE_MS)));
|
(leftMs != null ? leftMs : null)
|
||||||
const e = easeInQuad(t);
|
|
||||||
scale = 1 - (1 - SCALE_OUT_END) * e;
|
if (collapseLeft != null && collapseLeft <= COLLAPSE_MS) {
|
||||||
phaseAlpha = 1 - e;
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bisherige sanfte Gesamt-Opacity (verbleibende Lebenszeit)
|
// ── 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 lifeMs = totalMs
|
||||||
const fracLeft = leftMs == null ? 1 : Math.min(1, leftMs / lifeMs)
|
const fracLeft = leftMs == null ? 1 : Math.min(1, leftMs / lifeMs)
|
||||||
const baseOpacity = 0.75 + 0.25 * (1 - (1 - fracLeft)) // 0.75..1.0
|
baseOpacity = 0.75 + 0.25 * fracLeft
|
||||||
|
}
|
||||||
|
|
||||||
const overallOpacity = baseOpacity * phaseAlpha
|
const overallOpacity = baseOpacity * phaseAlpha
|
||||||
|
|
||||||
// Farbe (oder nimm ui.nade.smokeFill)
|
// Farbe (oder nimm ui.nade.smokeFill)
|
||||||
const fill = '#9bd4ff'
|
const fill = '#9bd4ff'
|
||||||
|
|
||||||
// kompaktere Form + runder oben (wie bei dir zuletzt)
|
// kompaktere Form
|
||||||
const R = rPx * 0.78
|
const R = rPx * 0.78
|
||||||
const lobes = [
|
const lobes = [
|
||||||
{ x: -R * 0.68, y: R * 0.14, r: R * 0.58, anim: '' },
|
{ x: -R * 0.68, y: R * 0.14, r: R * 0.58, anim: '' },
|
||||||
@ -156,9 +179,9 @@ export default function StaticEffects({
|
|||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={g.id}
|
key={g.id}
|
||||||
transform={`translate(${P.x}, ${P.y}) scale(${scale})`} // <- Zoom über Zeit
|
transform={`translate(${P.x}, ${P.y}) scale(${scale})`}
|
||||||
opacity={overallOpacity}
|
opacity={overallOpacity}
|
||||||
style={{ transformBox: 'fill-box', transformOrigin: 'center' }}
|
style={{ transformBox: 'fill-box', transformOrigin: 'center', pointerEvents: 'none' }}
|
||||||
>
|
>
|
||||||
{lobes.map((l, i) => (
|
{lobes.map((l, i) => (
|
||||||
<circle
|
<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>
|
</g>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const molotovNode = (g: Grenade) => {
|
const molotovNode = (g: Grenade) => {
|
||||||
const P = worldToPx(g.x, g.y)
|
const P = worldToPx(g.x, g.y)
|
||||||
const rPx = Math.max(ui.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
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
|
// nur die gewünschten statischen Effekte zeichnen
|
||||||
const nodes = grenades
|
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 => {
|
.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
|
||||||
|
|
||||||
if (g.kind === 'smoke') return smokeNode(g)
|
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 === 'decoy') return decoyNode(g)
|
||||||
if (g.kind === 'flash') return flashNode(g)
|
if (g.kind === 'flash') return flashNode(g)
|
||||||
return null
|
return null
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
// /src/app/radar/TeamSidebar.tsx
|
// /src/app/[locale]/components/radar/TeamSidebar.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore'
|
import { useAvatarDirectoryStore } from '@/lib/useAvatarDirectoryStore'
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /src/app/[locale]/components/radar/hooks/useBombBeep.ts
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { BombState } from '../lib/types';
|
import { BombState } from '../lib/types';
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /src/app/[locale]/components/radar/hooks/useOverview.ts
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Mapper, Overview } from '../lib/types';
|
import { Mapper, Overview } from '../lib/types';
|
||||||
import { defaultWorldToPx, parseOverviewJson, parseValveKvOverview } from '../lib/helpers';
|
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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { BombState, DeathMarker, Grenade, PlayerState, Score, Trail, WsStatus } from '../lib/types';
|
import { BombState, DeathMarker, Grenade, PlayerState, Score, Trail, WsStatus } from '../lib/types';
|
||||||
import { UI } from '../lib/ui';
|
import { UI } from '../lib/ui';
|
||||||
|
|||||||
@ -1,19 +1,186 @@
|
|||||||
|
// /src/app/[locale]/components/radar/lib/grenades.ts
|
||||||
import { Grenade } from './types';
|
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(
|
export function teamOfGrenade(
|
||||||
g: Grenade,
|
g: Grenade,
|
||||||
teamOfPlayer: (sid?: string|null)=>('T'|'CT'|string|null)
|
teamOfPlayer: (sid?: string | null) => 'T' | 'CT' | string | null
|
||||||
): 'T'|'CT'|string|null {
|
): 'T' | 'CT' | string | null {
|
||||||
if (g.team === 'T' || g.team === 'CT') return g.team;
|
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;
|
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[] {
|
export function normalizeGrenades(raw: any): Grenade[] {
|
||||||
// 👉 Hier bitte deinen bestehenden, langen Normalizer einfügen.
|
const arr = Array.isArray(raw) ? raw : Object.values(raw ?? {});
|
||||||
// Ich habe ihn aus Platzgründen nicht nochmal 1:1 kopiert.
|
const out: Grenade[] = [];
|
||||||
// Du kannst den Block aus deiner LiveRadar.tsx übernehmen und hier exportieren.
|
|
||||||
return [];
|
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';
|
import { Mapper, Overview } from './types';
|
||||||
|
|
||||||
export const RAD2DEG = 180 / Math.PI;
|
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 WsStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error';
|
||||||
|
|
||||||
export type PlayerState = {
|
export type PlayerState = {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// /src/app/[locale]/components/radar/lib/ui.ts
|
||||||
|
|
||||||
export const UI = {
|
export const UI = {
|
||||||
player: {
|
player: {
|
||||||
minRadiusPx: 4,
|
minRadiusPx: 4,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user