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
# META (vom CS2-Server-Plugin)
NEXT_PUBLIC_CS2_META_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_META_WS_PORT=443
NEXT_PUBLIC_CS2_META_WS_PATH=/telemetry
NEXT_PUBLIC_CS2_META_WS_SCHEME=wss
NEXT_PUBLIC_CS2_TELEMETRY_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_TELEMETRY_WS_PORT=443
NEXT_PUBLIC_CS2_TELEMETRY_WS_PATH=/telemetry
NEXT_PUBLIC_CS2_TELEMETRY_WS_SCHEME=wss
# POS (lokaler Aggregator)
NEXT_PUBLIC_CS2_POS_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_POS_WS_PORT=443
NEXT_PUBLIC_CS2_POS_WS_PATH=/positions
NEXT_PUBLIC_CS2_POS_WS_SCHEME=wss
# RADAR (lokaler Aggregator)
NEXT_PUBLIC_CS2_GAME_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_GAME_WS_PORT=443
NEXT_PUBLIC_CS2_GAME_WS_PATH=/game
NEXT_PUBLIC_CS2_GAME_WS_SCHEME=wss
NEXT_PUBLIC_CONNECT_HREF="steam://connect/94.130.66.149:27015/0000"

View File

@ -182,6 +182,35 @@ export default function Sidebar() {
Spielplan
</Button>
</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>
</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 = '',
}: SwitchProps) {
return (
<div className={`flex items-center gap-x-3 ${className}`}>
<div className={`flex items-center gap-x-3 cursor-pointer ${className}`}>
{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}
</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 AudioPrimer from "./components/AudioPrimer";
import ReadyOverlayHost from "./components/ReadyOverlayHost";
import TelemetrySocket from "./components/TelemetrySocket";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -39,6 +40,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<UserActivityTracker />
<AudioPrimer />
<ReadyOverlayHost />
<TelemetrySocket />
{/* App-Shell: Sidebar | Main */}
<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'
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

@ -1,4 +1,4 @@
// TeamSidebar.tsx
// /src/app/radar/TeamSidebar.tsx
'use client'
import React, { useEffect } from 'react'
import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore'
@ -79,7 +79,8 @@ export default function TeamSidebar({
return (
<div
key={p.id}
key={`player-${p.id}`}
id={`player-${p.id}`}
onMouseEnter={() => onHoverPlayer?.(p.id)}
onMouseLeave={() => onHoverPlayer?.(null)}
//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 LiveRadar from '@/app/components/radar/LiveRadar'
import LiveRadar from './LiveRadar';
export default function RadarPage({ params }: { params: { matchId: string } }) {
return (