updated radar

This commit is contained in:
Linrador 2025-09-11 14:01:50 +02:00
parent 3c68c3ad2c
commit 94bbaaa37e
15 changed files with 925 additions and 499 deletions

18
.env
View File

@ -21,15 +21,15 @@ PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
PTERO_SERVER_ID=37a11489 PTERO_SERVER_ID=37a11489
# META (vom CS2-Server-Plugin) # META (vom CS2-Server-Plugin)
NEXT_PUBLIC_CS2_META_WS_HOST=ironieopen.local NEXT_PUBLIC_CS2_TELEMETRY_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_META_WS_PORT=443 NEXT_PUBLIC_CS2_TELEMETRY_WS_PORT=443
NEXT_PUBLIC_CS2_META_WS_PATH=/telemetry NEXT_PUBLIC_CS2_TELEMETRY_WS_PATH=/telemetry
NEXT_PUBLIC_CS2_META_WS_SCHEME=wss NEXT_PUBLIC_CS2_TELEMETRY_WS_SCHEME=wss
# POS (lokaler Aggregator) # RADAR (lokaler Aggregator)
NEXT_PUBLIC_CS2_POS_WS_HOST=ironieopen.local NEXT_PUBLIC_CS2_GAME_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_POS_WS_PORT=443 NEXT_PUBLIC_CS2_GAME_WS_PORT=443
NEXT_PUBLIC_CS2_POS_WS_PATH=/positions NEXT_PUBLIC_CS2_GAME_WS_PATH=/game
NEXT_PUBLIC_CS2_POS_WS_SCHEME=wss NEXT_PUBLIC_CS2_GAME_WS_SCHEME=wss
NEXT_PUBLIC_CONNECT_HREF="steam://connect/94.130.66.149:27015/0000" NEXT_PUBLIC_CONNECT_HREF="steam://connect/94.130.66.149:27015/0000"

View File

@ -182,6 +182,35 @@ export default function Sidebar() {
Spielplan Spielplan
</Button> </Button>
</li> </li>
{/* Radar */}
<li>
<Button
onClick={() => { router.push('/radar'); setIsOpen(false) }}
size="sm"
variant="link"
className={`${navBtnBase} ${isActive('/radar') ? activeClasses : idleClasses}`}
>
<svg
className="size-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{/* äußerer Kreis */}
<circle cx="12" cy="12" r="10" />
{/* Sweep-Linie */}
<line x1="12" y1="12" x2="21" y2="12" />
{/* Zusatz-Ringe */}
<circle cx="12" cy="12" r="6" strokeDasharray="4 4" />
<circle cx="12" cy="12" r="3" />
</svg>
Radar
</Button>
</li>
</ul> </ul>
</nav> </nav>

View File

@ -0,0 +1,34 @@
'use client'
type WsStatus = 'idle' | 'connecting' | 'open' | 'closed' | 'error'
export default function StatusDot({
status,
label,
className = '',
}: {
status: WsStatus
label?: string
className?: string
}) {
const color =
status === 'open' ? 'bg-green-500' :
status === 'connecting' ? 'bg-amber-500' :
status === 'error' ? 'bg-red-500' :
'bg-neutral-400'
const text =
status === 'open' ? 'verbunden'
: status === 'connecting' ? 'verbinde…'
: status === 'error' ? 'Fehler'
: status === 'closed' ? 'getrennt'
: '—'
return (
<span className={`inline-flex items-center gap-1 text-xs opacity-80 ${className}`}>
{label && <span className="font-medium">{label}</span>}
<span className={`inline-block w-2.5 h-2.5 rounded-full ${color}`} />
{text}
</span>
)
}

View File

@ -21,9 +21,9 @@ export default function Switch({
className = '', className = '',
}: SwitchProps) { }: SwitchProps) {
return ( return (
<div className={`flex items-center gap-x-3 ${className}`}> <div className={`flex items-center gap-x-3 cursor-pointer ${className}`}>
{labelLeft && ( {labelLeft && (
<label htmlFor={id} className="text-sm text-gray-500 dark:text-neutral-400"> <label htmlFor={id} className="text-sm text-gray-500 dark:text-neutral-400 cursor-pointer">
{labelLeft} {labelLeft}
</label> </label>
)} )}

View File

@ -0,0 +1,95 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import { usePresenceStore } from '@/app/lib/usePresenceStore'
import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
const h = (host ?? '').trim() || '127.0.0.1'
const p = (port ?? '').trim() || '8081'
const pa = (path ?? '').trim() || '/telemetry'
const sch = (scheme ?? '').toLowerCase()
const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
const useWss = sch === 'wss' || (sch !== 'ws' && (p === '443' || pageHttps))
const proto = useWss ? 'wss' : 'ws'
const portPart = (p === '80' || p === '443') ? '' : `:${p}`
return `${proto}://${h}${portPart}${pa}`
}
export default function TelemetrySocket() {
const url = useMemo(
() => makeWsUrl(
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_HOST,
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_PORT,
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_PATH,
process.env.NEXT_PUBLIC_CS2_TELEMETRY_WS_SCHEME
),
[]
)
const setSnapshot = usePresenceStore(s => s.setSnapshot)
const setJoin = usePresenceStore(s => s.setJoin)
const setLeave = usePresenceStore(s => s.setLeave)
const setMapKey = useTelemetryStore(s => s.setMapKey)
// interne Refs für saubere Handler
const aliveRef = useRef(true)
const retryRef = useRef<number | null>(null)
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
aliveRef.current = true
const connect = () => {
if (!aliveRef.current || !url) return
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] open')
}
ws.onerror = () => {
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] error')
}
ws.onclose = () => {
if (process.env.NODE_ENV !== 'production') console.debug('[TelemetrySocket] closed')
if (aliveRef.current) retryRef.current = window.setTimeout(connect, 2000)
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (!msg) return
if (msg.type === 'players' && Array.isArray(msg.players)) {
// kompletter Presence-Snapshot
setSnapshot(msg.players)
} else if (msg.type === 'player_join' && msg.player) {
setJoin(msg.player)
} else if (msg.type === 'player_leave') {
const sid = msg.steamId ?? msg.steam_id ?? msg.id
if (sid != null) setLeave(sid)
}
// ⬇️ Map-Event aus /telemetry
if (msg.type === 'map' && typeof msg.name === 'string') {
const key = msg.name.toLowerCase()
if (process.env.NODE_ENV!=='production') console.debug('[TelemetrySocket] map:', key)
setMapKey(key)
}
}
}
connect()
return () => {
aliveRef.current = false
if (retryRef.current) window.clearTimeout(retryRef.current)
try { wsRef.current?.close(1000, 'telemetry socket unmounted') } catch {}
}
}, [url, setSnapshot, setJoin, setLeave])
return null
}

View File

@ -1,83 +0,0 @@
// MetaSocket.tsx
'use client'
import { useEffect, useRef } from 'react'
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
type MetaSocketProps = {
url?: string
onStatus?: (s: Status) => void
onMap?: (mapKey: string) => void
onPlayersSnapshot?: (list: any[]) => void
onPlayerJoin?: (p: any) => void
onPlayerLeave?: (steamId: string | number) => void
}
export default function MetaSocket({
url,
onStatus,
onMap,
onPlayersSnapshot,
onPlayerJoin,
onPlayerLeave,
}: MetaSocketProps) {
const wsRef = useRef<WebSocket | null>(null)
const aliveRef = useRef(true)
const retryRef = useRef<number | null>(null)
// aktuelle Handler in Refs spiegeln (ändern NICHT die Effect-Dependencies)
const onMapRef = useRef(onMap)
const onPlayersSnapshotRef = useRef(onPlayersSnapshot)
const onPlayerJoinRef = useRef(onPlayerJoin)
const onPlayerLeaveRef = useRef(onPlayerLeave)
useEffect(() => { onMapRef.current = onMap }, [onMap])
useEffect(() => { onPlayersSnapshotRef.current = onPlayersSnapshot }, [onPlayersSnapshot])
useEffect(() => { onPlayerJoinRef.current = onPlayerJoin }, [onPlayerJoin])
useEffect(() => { onPlayerLeaveRef.current = onPlayerLeave }, [onPlayerLeave])
useEffect(() => {
aliveRef.current = true
const connect = () => {
if (!aliveRef.current || !url) return
onStatus?.('connecting')
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => onStatus?.('open')
ws.onerror = () => onStatus?.('error')
ws.onclose = () => {
onStatus?.('closed')
// optional: Backoff oder ganz ohne Auto-Reconnect, je nach Wunsch
if (aliveRef.current) retryRef.current = window.setTimeout(connect, 2000)
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (!msg) return
if (msg.type === 'map' && typeof msg.name === 'string') {
onMapRef.current?.(msg.name.toLowerCase())
} else if (msg.type === 'players' && Array.isArray(msg.players)) {
onPlayersSnapshotRef.current?.(msg.players)
} else if (msg.type === 'player_join' && msg.player) {
onPlayerJoinRef.current?.(msg.player)
} else if (msg.type === 'player_leave') {
onPlayerLeaveRef.current?.(msg.steamId ?? msg.steam_id ?? msg.id)
}
}
}
connect()
return () => {
aliveRef.current = false
if (retryRef.current) window.clearTimeout(retryRef.current)
try { wsRef.current?.close(1000, 'meta unmounted') } catch {}
}
}, [url, onStatus]) // <— nur auf url/onStatus hören!
return null
}

View File

@ -1,93 +0,0 @@
// PositionsSocket.tsx
'use client'
import { useEffect, useRef } from 'react'
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
type PositionsSocketProps = {
url?: string
onStatus?: (s: Status) => void
onMap?: (mapKey: string) => void
onPlayerUpdate?: (p: any) => void
onPlayersAll?: (allplayers: any) => void
onGrenades?: (g: any) => void
onRoundStart?: () => void
onRoundEnd?: () => void
onBomb?: (b:any) => void
}
export default function PositionsSocket({
url,
onStatus,
onMap,
onPlayerUpdate,
onPlayersAll,
onGrenades,
onRoundStart,
onRoundEnd,
onBomb
}: PositionsSocketProps) {
const wsRef = useRef<WebSocket | null>(null)
const aliveRef = useRef(true)
const retryRef = useRef<number | null>(null)
const dispatch = (msg: any) => {
if (!msg) return;
if (msg.type === 'round_start') { onRoundStart?.(); return }
if (msg.type === 'round_end') { onRoundEnd?.(); return }
if (msg.type === 'tick') {
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase())
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}))
if (msg.grenades) onGrenades?.(msg.grenades)
if (msg.bomb) onBomb?.(msg.bomb)
return
}
if (msg.map && typeof msg.map.name === 'string') onMap?.(msg.map.name.toLowerCase())
if (msg.allplayers) onPlayersAll?.(msg)
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg)
if (msg.grenades && msg.type !== 'tick') onGrenades?.(msg.grenades)
const t = String(msg.type || '').toLowerCase();
if (msg.bomb || msg.c4 || t.startsWith('bomb_')) {
onBomb?.(msg);
}
}
useEffect(() => {
aliveRef.current = true
const connect = () => {
if (!aliveRef.current) return
onStatus?.('connecting')
const ws = new WebSocket(url!)
wsRef.current = ws
ws.onopen = () => onStatus?.('open')
ws.onerror = () => onStatus?.('error')
ws.onclose = () => {
onStatus?.('closed')
if (aliveRef.current) retryRef.current = window.setTimeout(connect, 2000)
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (Array.isArray(msg)) msg.forEach(dispatch)
else dispatch(msg)
}
}
if (url) connect()
return () => {
aliveRef.current = false
if (retryRef.current) window.clearTimeout(retryRef.current)
try { wsRef.current?.close(1000, 'positions unmounted') } catch {}
}
}, [url, onStatus])
return null
}

View File

@ -13,6 +13,7 @@ import SSEHandler from "./lib/SSEHandler";
import UserActivityTracker from "./components/UserActivityTracker"; import UserActivityTracker from "./components/UserActivityTracker";
import AudioPrimer from "./components/AudioPrimer"; import AudioPrimer from "./components/AudioPrimer";
import ReadyOverlayHost from "./components/ReadyOverlayHost"; import ReadyOverlayHost from "./components/ReadyOverlayHost";
import TelemetrySocket from "./components/TelemetrySocket";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -39,6 +40,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<UserActivityTracker /> <UserActivityTracker />
<AudioPrimer /> <AudioPrimer />
<ReadyOverlayHost /> <ReadyOverlayHost />
<TelemetrySocket />
{/* App-Shell: Sidebar | Main */} {/* App-Shell: Sidebar | Main */}
<div className="min-h-dvh grid grid-cols-1 sm:grid-cols-[16rem_1fr]"> <div className="min-h-dvh grid grid-cols-1 sm:grid-cols-[16rem_1fr]">

View File

@ -0,0 +1,83 @@
'use client'
import { create } from 'zustand'
type PresenceInfo = {
id: string
name?: string | null
team?: 'T' | 'CT' | string
joinedAt: number
}
type PresenceState = {
// online nach SteamID
online: Record<string, PresenceInfo>
setSnapshot: (players: Array<{ steamId: string|number; name?: string; team?: any }>) => void
setJoin: (p: { steamId: string|number; name?: string; team?: any }) => void
setLeave: (sid: string|number) => void
isOnline: (sid?: string|null) => boolean
onlineIds: () => string[]
}
const normTeam = (t: any): 'T' | 'CT' | string => {
if (t === 2 || t === 'T' || t === 't') return 'T'
if (t === 3 || t === 'CT' || t === 'ct') return 'CT'
return String(t ?? '')
}
const sidOf = (raw: any): string | null => {
const s = raw != null ? String(raw) : ''
return s && s !== '0' ? s : null
}
export const usePresenceStore = create<PresenceState>((set, get) => ({
online: {},
setSnapshot: (players) => {
const map: Record<string, PresenceInfo> = {}
const now = Date.now()
for (const p of players) {
const id = sidOf(p.steamId)
if (!id) continue
map[id] = {
id,
name: p.name ?? null,
team: normTeam(p.team),
joinedAt: now,
}
}
set({ online: map })
},
setJoin: (p) => {
const id = sidOf(p.steamId)
if (!id) return
set(s => ({
online: {
...s.online,
[id]: {
id,
name: p.name ?? s.online[id]?.name ?? null,
team: normTeam(p.team ?? s.online[id]?.team),
joinedAt: s.online[id]?.joinedAt ?? Date.now(),
}
}
}))
},
setLeave: (sid) => {
const id = sidOf(sid)
if (!id) return
set(s => {
const next = { ...s.online }
delete next[id]
return { online: next }
})
},
isOnline: (sid) => !!(sid && get().online[String(sid)]),
onlineIds: () => Object.keys(get().online),
}))
// Bequemer Hook: true/false
export const useIsPlayerOnline = (steamId?: string|null) =>
usePresenceStore(s => !!(steamId && s.online[String(steamId)]))

View File

@ -1,3 +1,5 @@
// /src/app/lib/useReadyOverlayStore.ts
'use client' 'use client'
import { create } from 'zustand' import { create } from 'zustand'

View File

@ -0,0 +1,13 @@
// /src/app/lib/useTelemetryStore.ts
'use client'
import { create } from 'zustand'
type State = {
mapKey: string | null
setMapKey: (k: string | null) => void
}
export const useTelemetryStore = create<State>((set) => ({
mapKey: null,
setMapKey: (k) => set({ mapKey: k }),
}))

View File

@ -0,0 +1,108 @@
// /src/app/radar/GameSocket.tsx
'use client'
import { useEffect, useRef } from 'react'
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
type GameSocketProps = {
url?: string
onStatus?: (s: Status) => void
onMap?: (mapKey: string) => void
onPlayerUpdate?: (p: any) => void
onPlayersAll?: (payload: any) => void
onGrenades?: (g: any) => void
onRoundStart?: () => void
onRoundEnd?: () => void
onBomb?: (b:any) => void
}
export default function GameSocket(props: GameSocketProps) {
const { url, onStatus, onMap, onPlayerUpdate, onPlayersAll, onGrenades, onRoundStart, onRoundEnd, onBomb } = props
const wsRef = useRef<WebSocket | null>(null)
const retryRef = useRef<number | null>(null)
const connectTimerRef = useRef<number | null>(null) // <- NEU
const shouldReconnectRef = useRef(true)
const dispatch = (msg: any) => {
if (!msg) return
if (msg.type === 'round_start') { onRoundStart?.(); return }
if (msg.type === 'round_end') { onRoundEnd?.(); return }
if (msg.type === 'tick') {
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase())
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}))
const g = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles
if (g) onGrenades?.(g)
if (msg.bomb) onBomb?.(msg.bomb)
onPlayersAll?.(msg)
return
}
// non-tick:
const g2 = msg.grenades ?? msg.projectiles ?? msg.nades ?? msg.grenadeProjectiles
if (g2 && msg.type !== 'tick') onGrenades?.(g2)
if (msg.map && typeof msg.map.name === 'string') onMap?.(msg.map.name.toLowerCase())
if (msg.allplayers) onPlayersAll?.(msg)
if (msg.player || msg.steamId || msg.position || msg.pos) onPlayerUpdate?.(msg)
if (msg.grenades && msg.type !== 'tick') onGrenades?.(msg.grenades)
const t = String(msg.type || '').toLowerCase()
if (msg.bomb || msg.c4 || t.startsWith('bomb_')) onBomb?.(msg)
}
useEffect(() => {
if (!url) return
shouldReconnectRef.current = true
// evtl. alte Ressourcen räumen
try { wsRef.current?.close(1000, 'replaced by new /radar visit') } catch {}
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
if (connectTimerRef.current) { window.clearTimeout(connectTimerRef.current); connectTimerRef.current = null }
const connect = () => {
if (!shouldReconnectRef.current) return
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return
onStatus?.('connecting')
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => onStatus?.('open')
ws.onerror = () => onStatus?.('error')
ws.onclose = () => {
onStatus?.('closed')
if (shouldReconnectRef.current) {
retryRef.current = window.setTimeout(connect, 2000)
}
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (Array.isArray(msg)) msg.forEach(dispatch)
else dispatch(msg)
}
}
// *** WICHTIG: leicht verzögert verbinden ***
// Verhindert das Fehl-Log im React-Strict-Mode (Mount->Unmount->Mount).
connectTimerRef.current = window.setTimeout(connect, 0)
return () => {
shouldReconnectRef.current = false
if (connectTimerRef.current) { window.clearTimeout(connectTimerRef.current); connectTimerRef.current = null }
if (retryRef.current) { window.clearTimeout(retryRef.current); retryRef.current = null }
const ws = wsRef.current
if (ws) {
ws.onclose = null // Reconnect nicht anstoßen
try { ws.close(1000, 'left /radar') } catch {}
}
onStatus?.('closed')
}
}, [url])
return null
}

View File

@ -2,11 +2,13 @@
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import MetaSocket from './MetaSocket' import GameSocket from './GameSocket'
import PositionsSocket from './PositionsSocket'
import TeamSidebar from './TeamSidebar' import TeamSidebar from './TeamSidebar'
import Switch from '../Switch' import Switch from '../components/Switch'
import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore' import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore'
import { useTelemetryStore } from '@/app/lib/useTelemetryStore'
import StatusDot from '../components/StatusDot'
import { useSession } from 'next-auth/react'
/* ───────── UI config ───────── */ /* ───────── UI config ───────── */
const UI = { const UI = {
@ -51,10 +53,24 @@ const UI = {
} }
/* ───────── helpers ───────── */ /* ───────── helpers ───────── */
const steamIdOf = (src:any): string | null => { const steamIdOf = (src: any): string | null => {
const raw = src?.steamId ?? src?.steam_id ?? src?.steamid const raw =
src?.steamId ?? src?.steam_id ?? src?.steamid ??
src?.id ?? src?.entityId ?? src?.entindex
const s = raw != null ? String(raw) : '' const s = raw != null ? String(raw) : ''
return s && s !== '0' ? s : null
// echte SteamIDs: 17-stellig
if (/^\d{17}$/.test(s)) return s
// '0' oder 'BOT' / kein valider Wert => Bot-Fallback
const name = (src?.name ?? src?.playerName ?? '').toString().trim()
if (name) return `BOT:${name}`
// letzter Versuch: irgend eine beständige ID
if (s && s !== '0' && s.toUpperCase() !== 'BOT') return s
return null
} }
function contrastStroke(hex: string) { function contrastStroke(hex: string) {
@ -117,21 +133,25 @@ function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string)
return `${proto}://${h}${portPart}${pa}` return `${proto}://${h}${portPart}${pa}`
} }
const metaUrl = makeWsUrl( const gameUrl = makeWsUrl(
process.env.NEXT_PUBLIC_CS2_META_WS_HOST, process.env.NEXT_PUBLIC_CS2_GAME_WS_HOST,
process.env.NEXT_PUBLIC_CS2_META_WS_PORT, process.env.NEXT_PUBLIC_CS2_GAME_WS_PORT,
process.env.NEXT_PUBLIC_CS2_META_WS_PATH, process.env.NEXT_PUBLIC_CS2_GAME_WS_PATH,
process.env.NEXT_PUBLIC_CS2_META_WS_SCHEME process.env.NEXT_PUBLIC_CS2_GAME_WS_SCHEME
)
const posUrl = makeWsUrl(
process.env.NEXT_PUBLIC_CS2_POS_WS_HOST,
process.env.NEXT_PUBLIC_CS2_POS_WS_PORT,
process.env.NEXT_PUBLIC_CS2_POS_WS_PATH,
process.env.NEXT_PUBLIC_CS2_POS_WS_SCHEME
) )
const DEFAULT_AVATAR = '/assets/img/avatars/default_steam_avatar.jpg' const DEFAULT_AVATAR = '/assets/img/avatars/default_steam_avatar.jpg'
const EQUIP_ICON: Record<string,string> = {
he: '/assets/img/icons/equipment/hegrenade.svg',
smoke: '/assets/img/icons/equipment/smokegrenade.svg',
flash: '/assets/img/icons/equipment/flashbang.svg',
decoy: '/assets/img/icons/equipment/decoy.svg',
molotov: '/assets/img/icons/equipment/molotov.svg',
incendiary: '/assets/img/icons/equipment/incgrenade.svg',
unknown: '/assets/img/icons/equipment/hegrenade.svg',
}
const RAD2DEG = 180 / Math.PI const RAD2DEG = 180 / Math.PI
const normalizeDeg = (d: number) => (d % 360 + 360) % 360 const normalizeDeg = (d: number) => (d % 360 + 360) % 360
const parseVec3String = (str?: string) => { const parseVec3String = (str?: string) => {
@ -168,13 +188,17 @@ type BombState = {
type Grenade = { type Grenade = {
id: string id: string
kind: 'smoke' | 'molotov' | 'he' | 'flash' | 'decoy' | 'unknown' kind: 'smoke' | 'molotov' | 'incendiary' | 'he' | 'flash' | 'decoy' | 'unknown'
x: number x: number
y: number y: number
z: number z: number
radius?: number | null radius?: number | null
expiresAt?: number | null expiresAt?: number | null
team?: 'T' | 'CT' | string | null team?: 'T' | 'CT' | string | null
phase?: 'projectile' | 'effect' | 'exploded' // fliegend / liegend (wirkt) / HE-Burst
headingRad?: number | null // Rotation fürs Icon (aus velocity)
spawnedAt?: number | null // für kurze Explosion-Animation
ownerId?: string | null // <- NEU: Werfer (SteamID)
} }
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 }
@ -185,9 +209,21 @@ type WsStatus = 'idle'|'connecting'|'open'|'closed'|'error'
/* ───────── Komponente ───────── */ /* ───────── Komponente ───────── */
export default function LiveRadar() { export default function LiveRadar() {
// Eingeloggter User
const { data: session, status } = useSession()
const isAuthed = status === 'authenticated'
// SteamID des aktuellen Users aus der Session (robust gegen verschiedene Feldnamen)
const mySteamId: string | null = (() => {
const u: any = session?.user
const cands = [u?.steamId, u?.steamid, u?.steam_id, u?.id]
const first = cands.find(Boolean)
return first ? String(first) : null
})()
// WS-Status // WS-Status
const [metaWsStatus, setMetaWsStatus] = useState<WsStatus>('idle') const [radarWsStatus, setGameWsStatus] = useState<WsStatus>('idle')
const [posWsStatus, setPosWsStatus] = useState<WsStatus>('idle')
// Map // Map
const [activeMapKey, setActiveMapKey] = useState<string | null>(null) const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
@ -230,6 +266,14 @@ export default function LiveRadar() {
if (players.length) ensureAvatars(players.map(p => p.id)) // p.id = SteamID if (players.length) ensureAvatars(players.map(p => p.id)) // p.id = SteamID
}, [players, ensureAvatars]) }, [players, ensureAvatars])
// Map-Key aus Telemetry übernehmen
const mapKeyFromTelemetry = useTelemetryStore(s => s.mapKey)
useEffect(() => {
if (mapKeyFromTelemetry) {
setActiveMapKey(mapKeyFromTelemetry)
}
}, [mapKeyFromTelemetry])
// Flush // Flush
const flushTimer = useRef<number | null>(null) const flushTimer = useRef<number | null>(null)
const scheduleFlush = () => { const scheduleFlush = () => {
@ -245,6 +289,36 @@ export default function LiveRadar() {
}, 66) }, 66)
} }
// --- GSI-Timerstate ---
const [roundPhase, setRoundPhase] =
useState<'freezetime'|'live'|'bomb'|'over'|'warmup'|'unknown'>('unknown')
const roundEndsAtRef = useRef<number|null>(null)
const bombEndsAtRef = useRef<number|null>(null)
const defuseRef = useRef<{ by: string|null; hasKit: boolean; endsAt: number|null }>({
by: null, hasKit: false, endsAt: null
})
// Kleiner Ticker, damit die Anzeigen "laufen"
const [, forceTick] = useState(0)
useEffect(() => {
const id = window.setInterval(() => forceTick(t => (t + 1) & 0xff), 200)
return () => window.clearInterval(id)
}, [])
// Formatierer
const secsLeft = (until: number|null) =>
until == null ? null : Math.max(0, Math.ceil((until - Date.now()) / 1000))
const fmtMMSS = (s: number) =>
`${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
const myTeam = useMemo<'T'|'CT'|string|null>(() => {
if (!mySteamId) return null
return playersRef.current.get(mySteamId)?.team ?? null
}, [players, mySteamId])
useEffect(() => { useEffect(() => {
return () => { return () => {
if (flushTimer.current != null) { if (flushTimer.current != null) {
@ -277,50 +351,6 @@ export default function LiveRadar() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeMapKey]) }, [activeMapKey])
/* ───────── Meta-Callbacks ───────── */
const handleMetaPlayersSnapshot = (list: Array<{ steamId: string|number; name?: string; team?: any }>) => {
for (const p of list) {
const id = steamIdOf(p)
if (!id) continue
const old = playersRef.current.get(id)
playersRef.current.set(id, {
id,
name: p.name ?? old?.name ?? null,
team: mapTeam(p.team ?? old?.team),
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null,
alive: old?.alive,
hasBomb: old?.hasBomb ?? false,
})
}
scheduleFlush()
}
const handleMetaPlayerJoin = (p: any) => {
const id = steamIdOf(p)
if (!id) return
const old = playersRef.current.get(id)
playersRef.current.set(id, {
id,
name: p?.name ?? old?.name ?? null,
team: mapTeam(p?.team ?? old?.team),
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null,
alive: true,
hasBomb: false,
})
scheduleFlush()
}
const handleMetaPlayerLeave = (steamId: string | number) => {
const id = String(steamId)
const old = playersRef.current.get(id)
if (old) {
playersRef.current.set(id, { ...old, alive: false })
scheduleFlush()
}
}
/* ───────── Bomben-Helper ───────── */ /* ───────── Bomben-Helper ───────── */
function pickVec3(src:any) { function pickVec3(src:any) {
const p = src?.pos ?? src?.position ?? src?.location ?? src?.coordinates const p = src?.pos ?? src?.position ?? src?.location ?? src?.coordinates
@ -378,7 +408,7 @@ export default function LiveRadar() {
} }
} }
/* ───────── Positions-Callbacks ───────── */ /* ───────── Radar-Callbacks ───────── */
const addDeathMarker = (x: number, y: number, steamId?: string) => { const addDeathMarker = (x: number, y: number, steamId?: string) => {
const now = Date.now() const now = Date.now()
if (steamId) { if (steamId) {
@ -434,160 +464,244 @@ export default function LiveRadar() {
} }
const handlePlayersAll = (msg: any) => { const handlePlayersAll = (msg: any) => {
const ap = msg?.allplayers // --- Rundenphase & Ende (läuft IMMER, auch wenn keine Player-Daten) ---
if (!ap || typeof ap !== 'object') return const pcd = msg?.phase ?? msg?.phase_countdowns
if (pcd?.phase_ends_in != null) {
let total = 0 const sec = Number(pcd.phase_ends_in)
let aliveCount = 0 if (Number.isFinite(sec)) {
roundEndsAtRef.current = Date.now() + sec * 1000
for (const key of Object.keys(ap)) { setRoundPhase(String(pcd.phase ?? 'unknown').toLowerCase() as any)
const p = ap[key]
// ✅ ID robust bestimmen: erst Payload, sonst Key (wenn 17-stellig)
const steamIdFromPayload = steamIdOf(p)
const keyLooksLikeSteamId = /^\d{17}$/.test(String(key))
const id = steamIdFromPayload ?? (keyLooksLikeSteamId ? String(key) : null)
if (!id) continue
// Position
const pos = parseVec3String(p.position ?? p.pos ?? p.location ?? p.coordinates)
// Yaw aus forward-Vektor, sonst Fallbacks
const fwd = parseVec3String(p.forward)
let yawDeg = Number.NaN
if (Number.isFinite(fwd.x) && Number.isFinite(fwd.y) && (fwd.x !== 0 || fwd.y !== 0)) {
yawDeg = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
} else {
const yawProbe = asNum(
p?.yaw ?? p?.viewAngle?.yaw ?? p?.view?.yaw ?? p?.aim?.yaw ?? p?.ang?.y ?? p?.angles?.y ?? p?.rotation?.yaw,
NaN
)
if (Number.isFinite(yawProbe)) yawDeg = normalizeDeg(yawProbe)
} }
} else if (pcd?.phase) {
setRoundPhase(String(pcd.phase).toLowerCase() as any)
}
if ((pcd?.phase ?? '').toLowerCase() === 'over') {
roundEndsAtRef.current = null
bombEndsAtRef.current = null
defuseRef.current = { by: null, hasKit: false, endsAt: null }
}
// --- Bomben-Countdown (falls vorhanden) ---
const b = msg?.bomb
if (b?.countdown != null && (b.state === 'planted' || b.state === 'defusing')) {
const sec = Number(b.countdown)
if (Number.isFinite(sec)) bombEndsAtRef.current = Date.now() + sec * 1000
} else if (!b || b.state === 'carried' || b.state === 'dropped' || b.state === 'defused') {
bombEndsAtRef.current = null
}
// --- Spieler verarbeiten: allplayers (Objekt) ODER players (Array) ---
const apObj = msg?.allplayers
const apArr = Array.isArray(msg?.players) ? msg.players : null
let total = 0, aliveCount = 0
const upsertFromPayload = (p:any) => {
const id = steamIdOf(p); if (!id) return
// Position
const pos = p.position ?? p.pos ?? p.location ?? p.coordinates ?? p.eye ?? p.pos
const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] }
: typeof pos === 'object' ? pos : { x: p.x, y: p.y, z: p.z }
const { x=0, y=0, z=0 } = xyz
// Yaw aus forward/fwd
const fwd = p.forward ?? p.fwd
let yawDeg = Number.NaN
if (fwd && (fwd.x !== 0 || fwd.y !== 0)) yawDeg = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
else if (Number.isFinite(Number(p.yaw))) yawDeg = normalizeDeg(Number(p.yaw))
const old = playersRef.current.get(id) const old = playersRef.current.get(id)
const hpNum = Number(p?.state?.health ?? p?.hp)
// Alive/HP/Armor/Equipment const armorNum = Number(p?.state?.armor ?? p?.armor)
const hpNum = Number(p?.state?.health) const isAlive = Number.isFinite(hpNum) ? hpNum > 0 : (old?.alive ?? true)
const armorNum = Number(p?.state?.armor) const helmet = Boolean(p?.state?.helmet ?? p?.helmet)
const isAlive = p?.state?.health != null ? p.state.health > 0 : (old?.alive ?? true) const defuse = Boolean(p?.state?.defusekit ?? p?.defusekit)
const helmet = !!p?.state?.helmet
const defuse = !!p?.state?.defusekit
// Bomben-Status
const hasBomb = detectHasBomb(p) || old?.hasBomb const hasBomb = detectHasBomb(p) || old?.hasBomb
// Death-Marker bei Death if ((old?.alive ?? true) && !isAlive) addDeathMarker(x, y, id)
if ((old?.alive ?? true) && !isAlive) {
addDeathMarker(pos.x, pos.y, id)
}
// Upsert
playersRef.current.set(id, { playersRef.current.set(id, {
id, id,
name: p?.name ?? old?.name ?? null, name: p?.name ?? old?.name ?? null,
team: mapTeam(p?.team ?? old?.team), team: mapTeam(p?.team ?? old?.team),
x: pos.x, y: pos.y, z: pos.z, x, y, z,
yaw: Number.isFinite(yawDeg) ? yawDeg : (old?.yaw ?? null), yaw: Number.isFinite(yawDeg) ? yawDeg : (old?.yaw ?? null),
alive: isAlive, alive: isAlive,
hasBomb: !!hasBomb, hasBomb: !!hasBomb,
hp: Number.isFinite(hpNum) ? hpNum : (old?.hp ?? null), hp: Number.isFinite(hpNum) ? hpNum : (old?.hp ?? null),
armor: Number.isFinite(armorNum) ? armorNum : (old?.armor ?? null), armor: Number.isFinite(armorNum) ? armorNum : (old?.armor ?? null),
helmet: helmet ?? (old?.helmet ?? null), helmet: typeof helmet === 'boolean' ? helmet : (old?.helmet ?? null),
defuse: defuse ?? (old?.defuse ?? null), defuse: typeof defuse === 'boolean' ? defuse : (old?.defuse ?? null),
}) })
total++; if (isAlive) aliveCount++
total++
if (isAlive) aliveCount++
} }
// Runde frisch (alle leben) → alte Artefakte weg if (apObj && typeof apObj === 'object') {
if ( for (const key of Object.keys(apObj)) upsertFromPayload(apObj[key])
total > 0 && } else if (apArr) {
aliveCount === total && for (const p of apArr) upsertFromPayload(p)
(deathMarkersRef.current.length > 0 || trailsRef.current.size > 0 || grenadesRef.current.size > 0) }
) {
if (total > 0 && aliveCount === total &&
(deathMarkersRef.current.length > 0 || trailsRef.current.size > 0 || grenadesRef.current.size > 0)) {
clearRoundArtifacts() clearRoundArtifacts()
} }
// (optional) Defuse-Schätzung /game liefert i. d. R. keine activity
defuseRef.current = { by: null, hasKit: false, endsAt: null }
// --- Scoreboard (robuste Extraktion) ---
try {
const pick = (v: any) => Number.isFinite(Number(v)) ? Number(v) : null
// Mögliche Orte: msg.score, msg.scores, msg.teams, msg.map.team_ct/team_t, o.ä.
const ct =
pick(msg?.score?.ct) ??
pick(msg?.scores?.ct) ??
pick(msg?.scores?.CT) ??
pick(msg?.teams?.ct?.score) ??
pick(msg?.teams?.CT?.score) ??
pick(msg?.map?.team_ct?.score) ??
pick(msg?.ctScore) ??
0
const t =
pick(msg?.score?.t) ??
pick(msg?.scores?.t) ??
pick(msg?.scores?.T) ??
pick(msg?.teams?.t?.score) ??
pick(msg?.teams?.T?.score) ??
pick(msg?.map?.team_t?.score) ??
pick(msg?.tScore) ??
0
const rnd =
pick(msg?.round) ??
pick(msg?.rounds?.played) ??
pick(msg?.map?.round) ??
null
setScore({ ct, t, round: rnd })
} catch {}
scheduleFlush() scheduleFlush()
} }
const normalizeGrenades = (raw: any): Grenade[] => { const normalizeGrenades = (raw: any): Grenade[] => {
if (!raw) return [] const out: Grenade[] = []
const now = Date.now()
const pickTeam = (t: any): 'T' | 'CT' | string | null => { const pickTeam = (t: any): 'T' | 'CT' | string | null => {
const s = mapTeam(t) const s = mapTeam(t)
return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? t : null) return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? s : null)
} }
if (Array.isArray(raw)) {
return raw.map((g: any, i: number) => { // Helper: baue eine Grenade
const pos = g.pos ?? g.position ?? g.location ?? {} 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] } : const xyz = Array.isArray(pos) ? { x: pos[0], y: pos[1], z: pos[2] } :
typeof pos === 'string' ? parseVec3String(pos) : pos 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 === 'molotov' || kind === 'incendiary') ? 120 :
kind === 'he' ? 280 : // für visuellen Burst
kind === 'flash' ? 36 :
kind === 'decoy' ? 80 : 60
return { return {
id: String(g.id ?? `${g.type ?? 'nade'}#${i}`), id: String(g?.id ?? g?.entityid ?? g?.entindex ?? `${kind}#${asNum(xyz?.x)}:${asNum(xyz?.y)}:${asNum(xyz?.z)}:${phase}`),
kind: (String(g.type ?? g.kind ?? 'unknown').toLowerCase() as Grenade['kind']), kind,
x: asNum(g.x ?? xyz?.x), y: asNum(g.y ?? xyz?.y), z: asNum(g.z ?? xyz?.z), x: asNum(xyz?.x), y: asNum(xyz?.y), z: asNum(xyz?.z),
radius: Number.isFinite(Number(g.radius)) ? Number(g.radius) : null, radius: Number.isFinite(Number(g?.radius)) ? Number(g?.radius) : defR,
expiresAt: Number.isFinite(Number(g.expiresAt)) ? Number(g.expiresAt) : null, expiresAt: Number.isFinite(Number(g?.expiresAt)) ? Number(g?.expiresAt) : null,
team: pickTeam(g.team ?? g.owner_team ?? g.side ?? null), team: pickTeam(g?.team ?? g?.owner_team ?? g?.side ?? null),
phase,
headingRad,
spawnedAt: now,
ownerId,
} }
})
} }
// 1) Projektile-Listen (versch. Namen)
const projLists = raw?.projectiles ?? raw?.grenadeProjectiles ?? raw?.nades ?? raw?.flying
if (projLists) {
const arr = Array.isArray(projLists) ? projLists : Object.values(projLists)
for (const g of arr) {
const k = String(g?.type ?? g?.kind ?? g?.weapon ?? 'unknown').toLowerCase()
const kind =
k.includes('smoke') ? 'smoke' :
(k.includes('molotov') || k.includes('incendiary') || k.includes('fire')) ? (k.includes('incendiary') ? 'incendiary' : 'molotov') :
k.includes('flash') ? 'flash' :
k.includes('decoy') ? 'decoy' :
(k.includes('he') || k.includes('frag')) ? 'he' : 'unknown'
out.push(make(g, kind, 'projectile'))
}
}
// 2) Effekt-Listen (stehende Wolke/Feuer etc.)
const buckets: Record<string, string[]> = { const buckets: Record<string, string[]> = {
smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'], smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'],
molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'], molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'],
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
} }
const out: Grenade[] = []
const push = (kind: Grenade['kind'], list: any) => { const pushEffects = (kind: Grenade['kind'], list:any) => {
if (!list) return
const arr = Array.isArray(list) ? list : Object.values(list) const arr = Array.isArray(list) ? list : Object.values(list)
let i = 0 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 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 })
const id = String(
g?.id ?? g?.entityid ?? g?.entindex ??
`${kind}#${asNum(xyz?.x)}:${asNum(xyz?.y)}:${asNum(xyz?.z)}:${i++}`
)
out.push({
id, kind,
x: asNum(xyz?.x), y: asNum(xyz?.y), z: asNum(xyz?.z),
radius: Number.isFinite(Number(g?.radius)) ? Number(g?.radius) : null,
expiresAt: Number.isFinite(Number(g?.expiresAt)) ? Number(g?.expiresAt) : null,
team: pickTeam(g?.team ?? g?.owner_team ?? g?.side ?? null),
})
}
} }
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]) push(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])
} }
if (out.length === 0 && typeof raw === 'object') { }
for (const [k, v] of Object.entries(raw)) {
const kk = k.toLowerCase() // 3) Falls raw ein Array ist (gemischt)
if (Array.isArray(raw)) {
for (const g of raw) {
const k = String(g?.type ?? g?.kind ?? 'unknown').toLowerCase()
const isEffect = (g?.expiresAt != null) || (g?.state && String(g.state).toLowerCase() !== 'projectile')
const phase: Grenade['phase'] =
k.includes('he') && (g?.exploded || g?.state === 'exploded') ? 'exploded' :
isEffect ? 'effect' : 'projectile'
const kind = const kind =
kk.includes('smoke') ? 'smoke' : k.includes('smoke') ? 'smoke' :
kk.includes('flash') ? 'flash' : (k.includes('molotov') || k.includes('incendiary') || k.includes('fire')) ? (k.includes('incendiary') ? 'incendiary' : 'molotov') :
kk.includes('molotov') || kk.includes('inferno') || kk.includes('fire') ? 'molotov' : k.includes('flash') ? 'flash' :
kk.includes('decoy') ? 'decoy' : k.includes('decoy') ? 'decoy' :
kk.includes('he') ? 'he' : 'unknown' k.includes('he') ? 'he' : 'unknown'
push(kind as Grenade['kind'], v) out.push(make(g, kind, phase))
} }
} }
return out return out
} }
const handleGrenades = (g: any) => { const handleGrenades = (g: any) => {
const list = normalizeGrenades(g) const list = normalizeGrenades(g)
const mine = mySteamId ? list.filter(n => n.ownerId === mySteamId) : []
const seen = new Set<string>() const seen = new Set<string>()
const now = Date.now() const now = Date.now()
for (const it of list) {
for (const it of mine) {
seen.add(it.id) seen.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]
@ -599,12 +713,13 @@ export default function LiveRadar() {
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 (!seen.has(id) && now - tr.lastSeen > UI.trail.fadeMs) trailsRef.current.delete(id)
} }
const next = new Map<string, Grenade>() const next = new Map<string, Grenade>()
for (const it of list) next.set(it.id, it) for (const it of mine) next.set(it.id, it)
grenadesRef.current = next grenadesRef.current = next
scheduleFlush() scheduleFlush()
@ -792,6 +907,21 @@ export default function LiveRadar() {
} }
}, [isBeepActive, bomb]) }, [isBeepActive, bomb])
// Scoreboard-State
const [score, setScore] = useState<{ ct: number; t: number; round?: number | null }>({ ct: 0, t: 0, round: null })
// Phase-Label hübsch machen
const phaseLabel = (() => {
switch (roundPhase) {
case 'freezetime': return 'Freeze'
case 'live': return 'Live'
case 'bomb': return 'Bomb'
case 'over': return 'Round over'
case 'warmup': return 'Warmup'
default: return '—'
}
})()
/* ───────── Status-Badge ───────── */ /* ───────── Status-Badge ───────── */
const WsDot = ({ status, label }: { status: WsStatus, label: string }) => { const WsDot = ({ status, label }: { status: WsStatus, label: string }) => {
const color = const color =
@ -813,6 +943,17 @@ export default function LiveRadar() {
) )
} }
if (!isAuthed) {
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center max-w-sm">
<h2 className="text-xl font-semibold mb-2">Live Radar</h2>
<p className="opacity-80">Bitte einloggen, um das Live-Radar zu sehen.</p>
</div>
</div>
)
}
/* ───────── 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">
@ -835,23 +976,14 @@ export default function LiveRadar() {
{/* rechts: Status */} {/* rechts: Status */}
<div className="flex-1 flex items-center justify-end gap-4"> <div className="flex-1 flex items-center justify-end gap-4">
<WsDot status={metaWsStatus} label="Meta" /> <StatusDot status={radarWsStatus} label="Positionsdaten" />
<WsDot status={posWsStatus} label="Pos" />
</div> </div>
</div> </div>
{/* Unsichtbare WS-Clients */} {/* Unsichtbare WS-Clients */}
<MetaSocket <GameSocket
url={metaUrl} url={gameUrl}
onStatus={setMetaWsStatus} onStatus={setGameWsStatus}
onMap={(k)=> setActiveMapKey(k.toLowerCase())}
onPlayersSnapshot={handleMetaPlayersSnapshot}
onPlayerJoin={handleMetaPlayerJoin}
onPlayerLeave={handleMetaPlayerLeave}
/>
<PositionsSocket
url={posUrl}
onStatus={setPosWsStatus}
onMap={(k)=> setActiveMapKey(String(k).toLowerCase())} onMap={(k)=> setActiveMapKey(String(k).toLowerCase())}
onPlayerUpdate={(p)=> { upsertPlayer(p); scheduleFlush() }} onPlayerUpdate={(p)=> { upsertPlayer(p); scheduleFlush() }}
onPlayersAll={(m)=> { handlePlayersAll(m); scheduleFlush() }} onPlayersAll={(m)=> { handlePlayersAll(m); scheduleFlush() }}
@ -903,10 +1035,11 @@ export default function LiveRadar() {
) : ( ) : (
<div className="h-full min-h-0 grid grid-cols-[minmax(180px,240px)_1fr_minmax(180px,240px)] gap-4"> <div className="h-full min-h-0 grid grid-cols-[minmax(180px,240px)_1fr_minmax(180px,240px)] gap-4">
{/* Left: T */} {/* Left: T */}
{myTeam !== 'CT' && (
<TeamSidebar <TeamSidebar
team="T" team="T"
players={players players={players
.filter(p => p.team === 'T') .filter(p => p.team === 'T' && (!myTeam || p.team === myTeam))
.map(p => ({ .map(p => ({
id: p.id, name: p.name, hp: p.hp, armor: p.armor, helmet: p.helmet, id: p.id, name: p.name, hp: p.hp, armor: p.armor, helmet: p.helmet,
defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive
@ -919,9 +1052,76 @@ export default function LiveRadar() {
avatarsById={avatarById} avatarsById={avatarById}
onHoverPlayer={setHoveredPlayerId} onHoverPlayer={setHoveredPlayerId}
/> />
)}
{/* Center: Radar */} {/* Center: Radar */}
<div className="relative min-h-0 rounded-lg overflow-hidden border border-neutral-700 bg-neutral-800"> <div className="relative min-h-0 rounded-lg overflow-hidden border border-neutral-700 bg-neutral-800">
{/* ── Topbar: Map-Title + Timer (vor dem Radar) ── */}
<div className="col-start-2 m-1 flex flex-col items-center gap-1">
{/* Map-Title */}
{activeMapKey && (
<div className="px-3 py-1 rounded bg-black/45 text-white text-xs sm:text-sm font-semibold tracking-wide">
{(() => {
const name = activeMapKey.replace(/^de_/, '').replace(/_/g, ' ')
return name ? name[0].toUpperCase() + name.slice(1) : ''
})()}
</div>
)}
{/* Timer-Zeile */}
{(() => {
const r = secsLeft(roundEndsAtRef.current)
const bomb = secsLeft(bombEndsAtRef.current)
const defuse = secsLeft(defuseRef.current.endsAt)
if (r == null && bomb == null && defuse == null) return null
return (
<div className="px-2 py-1 rounded bg-black/45 text-white text-[11px] sm:text-xs font-mono font-normal flex items-center justify-center gap-2">
{r != null && (<span title={`Rundenphase: ${roundPhase}`}>{fmtMMSS(r)}</span>)}
{bomb != null && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-500/70" title="C4">
<span aria-hidden>💣</span><span>{fmtMMSS(bomb)}</span>
</span>
)}
{defuse != null && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-blue-500/70"
title={defuseRef.current.hasKit ? 'Defuse mit Kit (5s)' : 'Defuse ohne Kit (10s)'}
>
<span aria-hidden>🛠</span><span>{fmtMMSS(defuse)}</span>
</span>
)}
</div>
)
})()}
{/* Score + Phase */}
<div className="mt-1 flex items-center gap-2">
{/* Score-Pill */}
<div className="px-2 py-0.5 rounded bg-black/45 text-white text-[11px] sm:text-xs font-semibold inline-flex items-center gap-2">
<span className="inline-flex items-center gap-1">
<span className="inline-block w-2.5 h-2.5 rounded-full" style={{ background: UI.player.fillCT }} />
<span>CT</span>
<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.5 h-2.5 rounded-full" style={{ background: UI.player.fillT }} />
<span>T</span>
<span className="tabular-nums">{score.t}</span>
</span>
{Number.isFinite(Number(score.round)) && (
<span className="ml-2 opacity-70 font-normal">R{Number(score.round)}</span>
)}
</div>
{/* Phase-Chip */}
<div className="px-2 py-0.5 rounded bg-black/45 text-white text-[11px] sm:text-xs font-medium">
{phaseLabel}
</div>
</div>
</div>
{currentSrc ? ( {currentSrc ? (
<div className="absolute inset-0"> <div className="absolute inset-0">
<img <img
@ -938,13 +1138,6 @@ export default function LiveRadar() {
}} }}
/> />
{/* Map-Title overlay (zentriert) */}
{activeMapKey && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20 px-3 py-1 rounded bg-black/45 text-white text-xs sm:text-sm font-semibold tracking-wide uppercase pointer-events-none">
{activeMapKey.replace(/^de_/, '').replace(/_/g, ' ')}
</div>
)}
{/* Overlay */} {/* Overlay */}
{imgSize && ( {imgSize && (
<svg <svg
@ -974,26 +1167,59 @@ export default function LiveRadar() {
) )
})} })}
{/* Grenades */} {/* Grenades: Projectiles + Effekte */}
{grenades.map((g) => { {grenades
.filter(g => !mySteamId || g.ownerId === mySteamId) // <- NEU: nur eigene
.map((g) => {
const P = worldToPx(g.x, g.y) const P = worldToPx(g.x, g.y)
const defR = if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
g.kind === 'smoke' ? 150 :
g.kind === 'molotov'? 120 : const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
g.kind === 'he' ? 40 :
g.kind === 'flash' ? 36 :
g.kind === 'decoy' ? 80 : 60
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? defR))
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
: g.team === 'T' ? UI.nade.teamStrokeT : g.team === 'T' ? UI.nade.teamStrokeT
: UI.nade.stroke : UI.nade.stroke
const sw = Math.max(1, Math.sqrt(rPx) * 0.6) const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
// 1) Projektil-Icon
if (g.phase === 'projectile') {
const size = Math.max(16, rPx * 0.7)
const href = EQUIP_ICON[g.kind] ?? EQUIP_ICON.unknown
const rot = (g.headingRad ?? 0) * 180 / Math.PI
return (
<g key={`nade-proj-${g.id}`} transform={`rotate(${rot} ${P.x} ${P.y})`}>
<image
href={href}
x={P.x - size/2}
y={P.y - size/2}
width={size}
height={size}
preserveAspectRatio="xMidYMid meet"
/>
</g>
)
}
// 2) HE-Explosion
if (g.kind === 'he' && g.phase === 'exploded') {
const base = Math.max(18, unitsToPx(22))
const dur = 450
const key = `he-burst-${g.id}-${g.spawnedAt}`
return (
<g key={key}>
<circle
cx={P.x} cy={P.y} r={base}
fill="none" stroke={UI.nade.heFill} strokeWidth={3}
style={{ transformBox:'fill-box', transformOrigin:'center', animation:`heExplode ${dur}ms ease-out 1` }}
/>
</g>
)
}
// 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} /> return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
} }
if (g.kind === 'molotov') { 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} /> return <circle key={g.id} cx={P.x} cy={P.y} r={rPx} fill={UI.nade.fireFill} stroke={stroke} strokeWidth={sw} />
} }
if (g.kind === 'decoy') { if (g.kind === 'decoy') {
@ -1009,7 +1235,9 @@ export default function LiveRadar() {
</g> </g>
) )
} }
return <circle key={g.id} cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill={g.kind === 'he' ? UI.nade.heFill : '#999'} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
// Fallback
return <circle key={g.id} cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill="#999" stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
})} })}
{/* Bombe */} {/* Bombe */}
@ -1070,7 +1298,7 @@ export default function LiveRadar() {
{/* Spieler */} {/* Spieler */}
{players {players
.filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false) .filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false && (!myTeam || p.team === myTeam))
.map((p) => { .map((p) => {
void avatarVersion void avatarVersion
const A = worldToPx(p.x, p.y) const A = worldToPx(p.x, p.y)
@ -1149,7 +1377,18 @@ export default function LiveRadar() {
stroke={ringColor} stroke={ringColor}
strokeWidth={Math.max(1.2, r * UI.player.avatarRingWidthRel)} strokeWidth={Math.max(1.2, r * UI.player.avatarRingWidthRel)}
/> />
</>
) : (
// Icons (wenn Avatare aus)
<circle
cx={A.x} cy={A.y} r={r}
fill={fillColor}
stroke={stroke}
strokeWidth={Math.max(1, r * 0.3)}
/>
)}
{/* Ping bei Spieler-Hover */}
{p.id === hoveredPlayerId && ( {p.id === hoveredPlayerId && (
<g> <g>
{/* dezenter statischer Ring */} {/* dezenter statischer Ring */}
@ -1185,16 +1424,6 @@ export default function LiveRadar() {
strokeWidth={Math.max(1.2, r * 0.15)} strokeWidth={Math.max(1.2, r * 0.15)}
/> />
)} )}
</>
) : (
// Icons (wenn Avatare aus)
<circle
cx={A.x} cy={A.y} r={r}
fill={fillColor}
stroke={stroke}
strokeWidth={Math.max(1, r * 0.3)}
/>
)}
{Number.isFinite(p.yaw as number) && ( {Number.isFinite(p.yaw as number) && (
useAvatars && isAvatar ? ( useAvatars && isAvatar ? (
@ -1268,11 +1497,12 @@ export default function LiveRadar() {
</div> </div>
{/* Right: CT */} {/* Right: CT */}
{myTeam !== 'T' && (
<TeamSidebar <TeamSidebar
team="CT" team="CT"
align="right" align="right"
players={players players={players
.filter(p => p.team === 'CT') .filter(p => p.team === 'CT' && (!myTeam || p.team === myTeam))
.map(p => ({ .map(p => ({
id: p.id, name: p.name, hp: p.hp, armor: p.armor, helmet: p.helmet, id: p.id, name: p.name, hp: p.hp, armor: p.armor, helmet: p.helmet,
defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive
@ -1285,6 +1515,7 @@ export default function LiveRadar() {
avatarsById={avatarById} avatarsById={avatarById}
onHoverPlayer={setHoveredPlayerId} onHoverPlayer={setHoveredPlayerId}
/> />
)}
</div> </div>
)} )}
</div> </div>
@ -1298,6 +1529,10 @@ export default function LiveRadar() {
from { transform: scale(1); opacity: 0.9; } from { transform: scale(1); opacity: 0.9; }
to { transform: scale(2.6); opacity: 0; } to { transform: scale(2.6); opacity: 0; }
} }
@keyframes heExplode {
0% { transform: scale(1); opacity: .85; }
100% { transform: scale(3.4); opacity: 0; }
}
`}</style> `}</style>
</div> </div>
) )

View File

@ -1,4 +1,4 @@
// TeamSidebar.tsx // /src/app/radar/TeamSidebar.tsx
'use client' 'use client'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore' import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore'
@ -79,7 +79,8 @@ export default function TeamSidebar({
return ( return (
<div <div
key={p.id} key={`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 bg-neutral-800/40 ${dead ? 'opacity-60' : ''}`}

View File

@ -1,7 +1,7 @@
// /app/match-details/[matchId]/radar/page.tsx // /src/app/radar/page.tsx
import Card from '@/app/components/Card' import Card from '@/app/components/Card'
import LiveRadar from '@/app/components/radar/LiveRadar' import LiveRadar from './LiveRadar';
export default function RadarPage({ params }: { params: { matchId: string } }) { export default function RadarPage({ params }: { params: { matchId: string } }) {
return ( return (