updated radar
This commit is contained in:
parent
3c68c3ad2c
commit
94bbaaa37e
18
.env
18
.env
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
34
src/app/components/StatusDot.tsx
Normal file
34
src/app/components/StatusDot.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
95
src/app/components/TelemetrySocket.tsx
Normal file
95
src/app/components/TelemetrySocket.tsx
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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]">
|
||||
|
||||
83
src/app/lib/usePresenceStore.ts
Normal file
83
src/app/lib/usePresenceStore.ts
Normal 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)]))
|
||||
@ -1,3 +1,5 @@
|
||||
// /src/app/lib/useReadyOverlayStore.ts
|
||||
|
||||
'use client'
|
||||
|
||||
import { create } from 'zustand'
|
||||
|
||||
13
src/app/lib/useTelemetryStore.ts
Normal file
13
src/app/lib/useTelemetryStore.ts
Normal 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 }),
|
||||
}))
|
||||
108
src/app/radar/GameSocket.tsx
Normal file
108
src/app/radar/GameSocket.tsx
Normal 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
|
||||
}
|
||||
@ -2,11 +2,13 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import MetaSocket from './MetaSocket'
|
||||
import PositionsSocket from './PositionsSocket'
|
||||
import GameSocket from './GameSocket'
|
||||
import TeamSidebar from './TeamSidebar'
|
||||
import Switch from '../Switch'
|
||||
import Switch from '../components/Switch'
|
||||
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 ───────── */
|
||||
const UI = {
|
||||
@ -52,9 +54,23 @@ const UI = {
|
||||
|
||||
/* ───────── helpers ───────── */
|
||||
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) : ''
|
||||
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) {
|
||||
@ -117,21 +133,25 @@ function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string)
|
||||
return `${proto}://${h}${portPart}${pa}`
|
||||
}
|
||||
|
||||
const metaUrl = makeWsUrl(
|
||||
process.env.NEXT_PUBLIC_CS2_META_WS_HOST,
|
||||
process.env.NEXT_PUBLIC_CS2_META_WS_PORT,
|
||||
process.env.NEXT_PUBLIC_CS2_META_WS_PATH,
|
||||
process.env.NEXT_PUBLIC_CS2_META_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 gameUrl = makeWsUrl(
|
||||
process.env.NEXT_PUBLIC_CS2_GAME_WS_HOST,
|
||||
process.env.NEXT_PUBLIC_CS2_GAME_WS_PORT,
|
||||
process.env.NEXT_PUBLIC_CS2_GAME_WS_PATH,
|
||||
process.env.NEXT_PUBLIC_CS2_GAME_WS_SCHEME
|
||||
)
|
||||
|
||||
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 normalizeDeg = (d: number) => (d % 360 + 360) % 360
|
||||
const parseVec3String = (str?: string) => {
|
||||
@ -168,13 +188,17 @@ type BombState = {
|
||||
|
||||
type Grenade = {
|
||||
id: string
|
||||
kind: 'smoke' | 'molotov' | 'he' | 'flash' | 'decoy' | 'unknown'
|
||||
kind: 'smoke' | 'molotov' | 'incendiary' | 'he' | 'flash' | 'decoy' | 'unknown'
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
radius?: number | null
|
||||
expiresAt?: number | 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 }
|
||||
@ -185,9 +209,21 @@ type WsStatus = 'idle'|'connecting'|'open'|'closed'|'error'
|
||||
|
||||
/* ───────── Komponente ───────── */
|
||||
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
|
||||
const [metaWsStatus, setMetaWsStatus] = useState<WsStatus>('idle')
|
||||
const [posWsStatus, setPosWsStatus] = useState<WsStatus>('idle')
|
||||
const [radarWsStatus, setGameWsStatus] = useState<WsStatus>('idle')
|
||||
|
||||
// Map
|
||||
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
|
||||
}, [players, ensureAvatars])
|
||||
|
||||
// Map-Key aus Telemetry übernehmen
|
||||
const mapKeyFromTelemetry = useTelemetryStore(s => s.mapKey)
|
||||
useEffect(() => {
|
||||
if (mapKeyFromTelemetry) {
|
||||
setActiveMapKey(mapKeyFromTelemetry)
|
||||
}
|
||||
}, [mapKeyFromTelemetry])
|
||||
|
||||
// Flush
|
||||
const flushTimer = useRef<number | null>(null)
|
||||
const scheduleFlush = () => {
|
||||
@ -245,6 +289,36 @@ export default function LiveRadar() {
|
||||
}, 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(() => {
|
||||
return () => {
|
||||
if (flushTimer.current != null) {
|
||||
@ -277,50 +351,6 @@ export default function LiveRadar() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 ───────── */
|
||||
function pickVec3(src:any) {
|
||||
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 now = Date.now()
|
||||
if (steamId) {
|
||||
@ -434,160 +464,244 @@ export default function LiveRadar() {
|
||||
}
|
||||
|
||||
const handlePlayersAll = (msg: any) => {
|
||||
const ap = msg?.allplayers
|
||||
if (!ap || typeof ap !== 'object') return
|
||||
|
||||
let total = 0
|
||||
let aliveCount = 0
|
||||
|
||||
for (const key of Object.keys(ap)) {
|
||||
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)
|
||||
// --- Rundenphase & Ende (läuft IMMER, auch wenn keine Player-Daten) ---
|
||||
const pcd = msg?.phase ?? msg?.phase_countdowns
|
||||
if (pcd?.phase_ends_in != null) {
|
||||
const sec = Number(pcd.phase_ends_in)
|
||||
if (Number.isFinite(sec)) {
|
||||
roundEndsAtRef.current = Date.now() + sec * 1000
|
||||
setRoundPhase(String(pcd.phase ?? 'unknown').toLowerCase() as any)
|
||||
}
|
||||
} 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)
|
||||
|
||||
// Alive/HP/Armor/Equipment
|
||||
const hpNum = Number(p?.state?.health)
|
||||
const armorNum = Number(p?.state?.armor)
|
||||
const isAlive = p?.state?.health != null ? p.state.health > 0 : (old?.alive ?? true)
|
||||
const helmet = !!p?.state?.helmet
|
||||
const defuse = !!p?.state?.defusekit
|
||||
|
||||
// Bomben-Status
|
||||
const hpNum = Number(p?.state?.health ?? p?.hp)
|
||||
const armorNum = Number(p?.state?.armor ?? p?.armor)
|
||||
const isAlive = Number.isFinite(hpNum) ? hpNum > 0 : (old?.alive ?? true)
|
||||
const helmet = Boolean(p?.state?.helmet ?? p?.helmet)
|
||||
const defuse = Boolean(p?.state?.defusekit ?? p?.defusekit)
|
||||
const hasBomb = detectHasBomb(p) || old?.hasBomb
|
||||
|
||||
// Death-Marker bei Death
|
||||
if ((old?.alive ?? true) && !isAlive) {
|
||||
addDeathMarker(pos.x, pos.y, id)
|
||||
}
|
||||
if ((old?.alive ?? true) && !isAlive) addDeathMarker(x, y, id)
|
||||
|
||||
// Upsert
|
||||
playersRef.current.set(id, {
|
||||
id,
|
||||
name: p?.name ?? old?.name ?? null,
|
||||
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),
|
||||
alive: isAlive,
|
||||
hasBomb: !!hasBomb,
|
||||
hp: Number.isFinite(hpNum) ? hpNum : (old?.hp ?? null),
|
||||
armor: Number.isFinite(armorNum) ? armorNum : (old?.armor ?? null),
|
||||
helmet: helmet ?? (old?.helmet ?? null),
|
||||
defuse: defuse ?? (old?.defuse ?? null),
|
||||
helmet: typeof helmet === 'boolean' ? helmet : (old?.helmet ?? 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 (
|
||||
total > 0 &&
|
||||
aliveCount === total &&
|
||||
(deathMarkersRef.current.length > 0 || trailsRef.current.size > 0 || grenadesRef.current.size > 0)
|
||||
) {
|
||||
if (apObj && typeof apObj === 'object') {
|
||||
for (const key of Object.keys(apObj)) upsertFromPayload(apObj[key])
|
||||
} else if (apArr) {
|
||||
for (const p of apArr) upsertFromPayload(p)
|
||||
}
|
||||
|
||||
if (total > 0 && aliveCount === total &&
|
||||
(deathMarkersRef.current.length > 0 || trailsRef.current.size > 0 || grenadesRef.current.size > 0)) {
|
||||
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()
|
||||
}
|
||||
|
||||
const normalizeGrenades = (raw: any): Grenade[] => {
|
||||
if (!raw) return []
|
||||
const out: Grenade[] = []
|
||||
const now = Date.now()
|
||||
|
||||
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
|
||||
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) => {
|
||||
const pos = g.pos ?? g.position ?? g.location ?? {}
|
||||
|
||||
// Helper: baue eine Grenade
|
||||
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] } :
|
||||
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 {
|
||||
id: String(g.id ?? `${g.type ?? 'nade'}#${i}`),
|
||||
kind: (String(g.type ?? g.kind ?? 'unknown').toLowerCase() as Grenade['kind']),
|
||||
x: asNum(g.x ?? xyz?.x), y: asNum(g.y ?? xyz?.y), z: asNum(g.z ?? 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),
|
||||
id: String(g?.id ?? g?.entityid ?? g?.entindex ?? `${kind}#${asNum(xyz?.x)}:${asNum(xyz?.y)}:${asNum(xyz?.z)}:${phase}`),
|
||||
kind,
|
||||
x: asNum(xyz?.x), y: asNum(xyz?.y), z: asNum(xyz?.z),
|
||||
radius: Number.isFinite(Number(g?.radius)) ? Number(g?.radius) : defR,
|
||||
expiresAt: Number.isFinite(Number(g?.expiresAt)) ? Number(g?.expiresAt) : 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[]> = {
|
||||
smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'],
|
||||
molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'],
|
||||
he: ['he', 'hegrenade', 'hegrenades', 'explosive'],
|
||||
flash: ['flash', 'flashbang', 'flashbangs'],
|
||||
decoy: ['decoy', 'decoys'],
|
||||
incendiary: ['incendiary', 'incgrenade'] // falls getrennt geliefert
|
||||
}
|
||||
const out: Grenade[] = []
|
||||
const push = (kind: Grenade['kind'], list: any) => {
|
||||
if (!list) return
|
||||
|
||||
const pushEffects = (kind: Grenade['kind'], list:any) => {
|
||||
const arr = Array.isArray(list) ? list : Object.values(list)
|
||||
let i = 0
|
||||
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),
|
||||
})
|
||||
}
|
||||
for (const g of arr) out.push(make(g, kind, kind === 'he' && (g?.exploded || g?.state === 'exploded') ? 'exploded' : 'effect'))
|
||||
}
|
||||
|
||||
if (typeof raw === 'object') {
|
||||
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 =
|
||||
kk.includes('smoke') ? 'smoke' :
|
||||
kk.includes('flash') ? 'flash' :
|
||||
kk.includes('molotov') || kk.includes('inferno') || kk.includes('fire') ? 'molotov' :
|
||||
kk.includes('decoy') ? 'decoy' :
|
||||
kk.includes('he') ? 'he' : 'unknown'
|
||||
push(kind as Grenade['kind'], v)
|
||||
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') ? 'he' : 'unknown'
|
||||
out.push(make(g, kind, phase))
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const handleGrenades = (g: any) => {
|
||||
const list = normalizeGrenades(g)
|
||||
|
||||
const mine = mySteamId ? list.filter(n => n.ownerId === mySteamId) : []
|
||||
|
||||
const seen = new Set<string>()
|
||||
const now = Date.now()
|
||||
for (const it of list) {
|
||||
|
||||
for (const it of mine) {
|
||||
seen.add(it.id)
|
||||
const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 }
|
||||
const last = prev.pts[prev.pts.length - 1]
|
||||
@ -599,12 +713,13 @@ export default function LiveRadar() {
|
||||
prev.lastSeen = now
|
||||
trailsRef.current.set(it.id, prev)
|
||||
}
|
||||
|
||||
for (const [id, tr] of trailsRef.current) {
|
||||
if (!seen.has(id) && now - tr.lastSeen > UI.trail.fadeMs) trailsRef.current.delete(id)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
scheduleFlush()
|
||||
@ -792,6 +907,21 @@ export default function LiveRadar() {
|
||||
}
|
||||
}, [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 ───────── */
|
||||
const WsDot = ({ status, label }: { status: WsStatus, label: string }) => {
|
||||
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 ───────── */
|
||||
return (
|
||||
<div className="h-full min-h-0 w-full flex flex-col overflow-hidden">
|
||||
@ -835,23 +976,14 @@ export default function LiveRadar() {
|
||||
|
||||
{/* rechts: Status */}
|
||||
<div className="flex-1 flex items-center justify-end gap-4">
|
||||
<WsDot status={metaWsStatus} label="Meta" />
|
||||
<WsDot status={posWsStatus} label="Pos" />
|
||||
<StatusDot status={radarWsStatus} label="Positionsdaten" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unsichtbare WS-Clients */}
|
||||
<MetaSocket
|
||||
url={metaUrl}
|
||||
onStatus={setMetaWsStatus}
|
||||
onMap={(k)=> setActiveMapKey(k.toLowerCase())}
|
||||
onPlayersSnapshot={handleMetaPlayersSnapshot}
|
||||
onPlayerJoin={handleMetaPlayerJoin}
|
||||
onPlayerLeave={handleMetaPlayerLeave}
|
||||
/>
|
||||
<PositionsSocket
|
||||
url={posUrl}
|
||||
onStatus={setPosWsStatus}
|
||||
<GameSocket
|
||||
url={gameUrl}
|
||||
onStatus={setGameWsStatus}
|
||||
onMap={(k)=> setActiveMapKey(String(k).toLowerCase())}
|
||||
onPlayerUpdate={(p)=> { upsertPlayer(p); 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">
|
||||
{/* Left: T */}
|
||||
{myTeam !== 'CT' && (
|
||||
<TeamSidebar
|
||||
team="T"
|
||||
players={players
|
||||
.filter(p => p.team === 'T')
|
||||
.filter(p => p.team === 'T' && (!myTeam || p.team === myTeam))
|
||||
.map(p => ({
|
||||
id: p.id, name: p.name, hp: p.hp, armor: p.armor, helmet: p.helmet,
|
||||
defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive
|
||||
@ -919,9 +1052,76 @@ export default function LiveRadar() {
|
||||
avatarsById={avatarById}
|
||||
onHoverPlayer={setHoveredPlayerId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Center: Radar */}
|
||||
<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 ? (
|
||||
<div className="absolute inset-0">
|
||||
<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 */}
|
||||
{imgSize && (
|
||||
<svg
|
||||
@ -974,26 +1167,59 @@ export default function LiveRadar() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Grenades */}
|
||||
{grenades.map((g) => {
|
||||
{/* Grenades: Projectiles + Effekte */}
|
||||
{grenades
|
||||
.filter(g => !mySteamId || g.ownerId === mySteamId) // <- NEU: nur eigene
|
||||
.map((g) => {
|
||||
const P = worldToPx(g.x, g.y)
|
||||
const defR =
|
||||
g.kind === 'smoke' ? 150 :
|
||||
g.kind === 'molotov'? 120 :
|
||||
g.kind === 'he' ? 40 :
|
||||
g.kind === 'flash' ? 36 :
|
||||
g.kind === 'decoy' ? 80 : 60
|
||||
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? defR))
|
||||
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
||||
|
||||
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? 60))
|
||||
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
|
||||
: g.team === 'T' ? UI.nade.teamStrokeT
|
||||
: UI.nade.stroke
|
||||
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') {
|
||||
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} />
|
||||
}
|
||||
if (g.kind === 'decoy') {
|
||||
@ -1009,7 +1235,9 @@ export default function LiveRadar() {
|
||||
</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 */}
|
||||
@ -1070,7 +1298,7 @@ export default function LiveRadar() {
|
||||
|
||||
{/* Spieler */}
|
||||
{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) => {
|
||||
void avatarVersion
|
||||
const A = worldToPx(p.x, p.y)
|
||||
@ -1149,7 +1377,18 @@ export default function LiveRadar() {
|
||||
stroke={ringColor}
|
||||
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 && (
|
||||
<g>
|
||||
{/* dezenter statischer Ring */}
|
||||
@ -1185,16 +1424,6 @@ export default function LiveRadar() {
|
||||
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) && (
|
||||
useAvatars && isAvatar ? (
|
||||
@ -1268,11 +1497,12 @@ export default function LiveRadar() {
|
||||
</div>
|
||||
|
||||
{/* Right: CT */}
|
||||
{myTeam !== 'T' && (
|
||||
<TeamSidebar
|
||||
team="CT"
|
||||
align="right"
|
||||
players={players
|
||||
.filter(p => p.team === 'CT')
|
||||
.filter(p => p.team === 'CT' && (!myTeam || p.team === myTeam))
|
||||
.map(p => ({
|
||||
id: p.id, name: p.name, hp: p.hp, armor: p.armor, helmet: p.helmet,
|
||||
defuse: p.defuse, hasBomb: p.hasBomb, alive: p.alive
|
||||
@ -1285,6 +1515,7 @@ export default function LiveRadar() {
|
||||
avatarsById={avatarById}
|
||||
onHoverPlayer={setHoveredPlayerId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -1298,6 +1529,10 @@ export default function LiveRadar() {
|
||||
from { transform: scale(1); opacity: 0.9; }
|
||||
to { transform: scale(2.6); opacity: 0; }
|
||||
}
|
||||
@keyframes heExplode {
|
||||
0% { transform: scale(1); opacity: .85; }
|
||||
100% { transform: scale(3.4); opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
@ -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' : ''}`}
|
||||
@ -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 (
|
||||
Loading…
x
Reference in New Issue
Block a user