Merge branch 'main' of https://git.rother-woelki.de/chris/ironie-nextjs
This commit is contained in:
commit
f1773a0924
10
.env
10
.env
@ -8,10 +8,6 @@ DATABASE_URL="postgresql://postgres:Timmy0104199%3F@localhost:5432/ironie"
|
||||
SHARE_CODE_SECRET_KEY=6f9d4a2951b8eae35cdd3fb28e1a74550d177c3900ad1111c8e48b4e3b39bba4
|
||||
SHARE_CODE_IV=9f1d67b8a3c4d261fa2b7c44a1d4f9c8
|
||||
STEAM_API_KEY=0B3B2BF79ECD1E9262BB118A7FEF1973
|
||||
STEAM_USERNAME=ironiebot
|
||||
STEAM_PASSWORD=QGEgGxaQoIFz16rDvMcO
|
||||
STEAM_SHARED_SECRET=test
|
||||
STEAMCMD_PATH=C:\Users\Rother\Desktop\dev\ironie\steamcmd\steamcmd.exe
|
||||
NEXTAUTH_SECRET=ironieopen
|
||||
NEXTAUTH_URL=https://ironieopen.local
|
||||
AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev
|
||||
@ -23,6 +19,6 @@ PTERO_SERVER_SFTP_URL=sftp://panel.ironieopen.de:2022
|
||||
PTERO_SERVER_SFTP_USER=army.37a11489
|
||||
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
|
||||
PTERO_SERVER_ID=37a11489
|
||||
NEXT_PUBLIC_CS2_WS_URL=wss://ws.ironieopen.de:8081/telemetry
|
||||
NEXT_PUBLIC_CS2_WS_HOST=ws.ironieopen.de
|
||||
NEXT_PUBLIC_CS2_WS_PORT=8081
|
||||
NEXT_PUBLIC_CS2_WS_HOST=ironieopen.local
|
||||
NEXT_PUBLIC_CS2_WS_PORT=443
|
||||
NEXT_PUBLIC_CS2_WS_PATH=/telemetry
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@ -45,6 +45,7 @@
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"undici": "^7.15.0",
|
||||
"vanilla-calendar-pro": "^3.0.4",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
@ -7957,6 +7958,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.15.0.tgz",
|
||||
"integrity": "sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.19.8",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
"undici": "^7.15.0",
|
||||
"vanilla-calendar-pro": "^3.0.4",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
|
||||
@ -2,133 +2,35 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
// ---------- Konfiguration (UI & Verhalten) ----------
|
||||
/* ───────────────── UI ───────────────── */
|
||||
const UI = {
|
||||
player: {
|
||||
minRadiusPx: 4,
|
||||
radiusRel: 0.008, // Radius relativ zur kleineren Bildkante
|
||||
dirLenRel: 0.70, // Anteil des Radius, Linie bleibt im Kreis
|
||||
radiusRel: 0.008, // relativ zur kleineren Bildkante
|
||||
dirLenRel: 0.70, // Anteil des Radius
|
||||
dirMinLenPx: 6,
|
||||
lineWidthRel: 0.25, // Linienbreite relativ zum Radius
|
||||
lineWidthRel: 0.25,
|
||||
stroke: '#ffffff',
|
||||
fillCT: '#3b82f6',
|
||||
fillT: '#f59e0b',
|
||||
// 'auto' = automatisch kontrastierend zum Kreis, sonst fixe Farbe wie '#fff'
|
||||
dirColor: 'auto' as 'auto' | string,
|
||||
dirColor: 'auto' as 'auto' | string, // 'auto' = Kontrast zum Kreis
|
||||
},
|
||||
effects: {
|
||||
smokeIconScale: 1.6,
|
||||
fireIconScale: 1.45,
|
||||
smokeOpacity: 0.95,
|
||||
smokeFillOpacity: 0.70,
|
||||
fireOpacity: 1,
|
||||
smokeFadeMs: 3000,
|
||||
|
||||
/* ───────────────── UI (Grenades) ───────────────── */
|
||||
nade: {
|
||||
stroke: '#111111',
|
||||
smokeFill: 'rgba(160,160,160,0.35)',
|
||||
fireFill: 'rgba(255,128,0,0.35)',
|
||||
heFill: 'rgba(90,160,90,0.9)',
|
||||
flashFill: 'rgba(255,255,255,0.95)',
|
||||
decoyFill: 'rgba(140,140,255,0.25)',
|
||||
teamStrokeCT: '#3b82f6',
|
||||
teamStrokeT: '#f59e0b',
|
||||
minRadiusPx: 6
|
||||
}
|
||||
}
|
||||
|
||||
const RAD2DEG = 180 / Math.PI
|
||||
|
||||
function normalizeDeg(d: number) {
|
||||
d = d % 360
|
||||
return d < 0 ? d + 360 : d
|
||||
}
|
||||
|
||||
function shortestAngleDeltaDeg(a: number, b: number) {
|
||||
// delta in [-180, 180)
|
||||
return ((b - a + 540) % 360) - 180
|
||||
}
|
||||
|
||||
function lerpAngleDeg(a: number, b: number, t: number) {
|
||||
return normalizeDeg(a + shortestAngleDeltaDeg(a, b) * t)
|
||||
}
|
||||
|
||||
// Yaw-Quelle parsen (Server liefert Grad)
|
||||
function toYawDegMaybe(raw: any): number | null {
|
||||
const v = Number(raw)
|
||||
return Number.isFinite(v) ? v : null
|
||||
}
|
||||
|
||||
// Fallback, wenn keine yaw übermittelt wird
|
||||
function deriveYawDeg(raw: any, prev: PlayerState | undefined, x: number, y: number): number {
|
||||
const fromRaw = toYawDegMaybe(raw)
|
||||
if (fromRaw != null) return normalizeDeg(fromRaw)
|
||||
|
||||
if (prev) {
|
||||
const dx = x - prev.x, dy = y - prev.y
|
||||
if (Math.hypot(dx, dy) > 1) return normalizeDeg(Math.atan2(dy, dx) * RAD2DEG)
|
||||
if (Number.isFinite(prev.yaw)) return prev.yaw
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function mapTeam(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 ?? '')
|
||||
}
|
||||
|
||||
type Props = { matchId: string }
|
||||
|
||||
// ---- API (MapVote) ----------------------------------------------------------
|
||||
type ApiStep = { action: 'ban' | 'pick' | 'decider'; map?: string | null }
|
||||
type ApiResponse = {
|
||||
steps: ApiStep[]
|
||||
mapVisuals?: Record<string, { label: string; bg: string }>
|
||||
}
|
||||
|
||||
// ---- Telemetry Player -------------------------------------------------------
|
||||
type PlayerState = {
|
||||
id: string
|
||||
name?: string | null
|
||||
team?: 'T' | 'CT' | string
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
yaw: number // 0 -> +X, 90 -> +Y (Welt)
|
||||
alive?: boolean
|
||||
}
|
||||
|
||||
// ---- Overview (HLTV) --------------------------------------------------------
|
||||
type Overview = { posX: number; posY: number; scale: number; rotate?: number }
|
||||
|
||||
// ---- Effekte (Smoke/Fire) ---------------------------------------------------
|
||||
type EffectType = 'smoke' | 'fire'
|
||||
type Effect = {
|
||||
id: string
|
||||
type: 'smoke' | 'fire'
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
startMs: number
|
||||
ttlMs: number
|
||||
ending?: boolean
|
||||
fadeUntil?: number
|
||||
}
|
||||
|
||||
// --- Nade-Pfade --------------------------------------------------------------
|
||||
type NadePoint = { x: number; y: number; z?: number; t?: number; s?: number }
|
||||
type NadePath = {
|
||||
id: string
|
||||
kind: string
|
||||
points: NadePoint[]
|
||||
startedMs: number
|
||||
endedMs?: number
|
||||
}
|
||||
|
||||
const NADE_PATH_TTL = 6000 // Pfad noch 6s nach Detonation zeigen
|
||||
|
||||
function nadeColor(kind: string) {
|
||||
const k = String(kind || '').toLowerCase()
|
||||
if (k.includes('smoke')) return '#94a3b8'
|
||||
if (k.includes('flash')) return '#fbbf24'
|
||||
if (k.includes('molotov') || k.includes('incen') || k === 'fire') return '#f97316'
|
||||
if (k.includes('he') || k.includes('frag')) return '#ef4444'
|
||||
if (k.includes('decoy')) return '#22c55e'
|
||||
return '#a3a3a3'
|
||||
}
|
||||
|
||||
function contrastStroke(hex: string) {
|
||||
const h = hex.replace('#','')
|
||||
const r = parseInt(h.slice(0,2),16)/255
|
||||
@ -139,211 +41,263 @@ function contrastStroke(hex: string) {
|
||||
return L > 0.6 ? '#111111' : '#ffffff'
|
||||
}
|
||||
|
||||
export default function LiveRadar({ matchId }: Props) {
|
||||
// ---- MapVote (Backup)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [voteData, setVoteData] = useState<ApiResponse | null>(null)
|
||||
|
||||
// ---- WS
|
||||
const [wsStatus, setWsStatus] =
|
||||
useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
|
||||
|
||||
// ---- Layout: Bild maximal Viewport-Höhe
|
||||
const headerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [maxImgHeight, setMaxImgHeight] = useState<number | null>(null)
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const bottom = headerRef.current?.getBoundingClientRect().bottom ?? 0
|
||||
setMaxImgHeight(Math.max(120, Math.floor(window.innerHeight - bottom - 16)))
|
||||
function mapTeam(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 ?? '')
|
||||
}
|
||||
update()
|
||||
window.addEventListener('resize', update)
|
||||
window.addEventListener('scroll', update, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('resize', update)
|
||||
window.removeEventListener('scroll', update)
|
||||
|
||||
const RAD2DEG = 180 / Math.PI;
|
||||
|
||||
function normalizeDeg(d: number) {
|
||||
d = d % 360;
|
||||
return d < 0 ? d + 360 : d;
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ---- Effekt-SVG-Paths
|
||||
const SMOKE_PATH = "M32 400C32 479.5 96.5 544 176 544L480 544C550.7 544 608 486.7 608 416C608 364.4 577.5 319.9 533.5 299.7C540.2 286.6 544 271.7 544 256C544 203 501 160 448 160C430.3 160 413.8 164.8 399.6 173.1C375.5 127.3 327.4 96 272 96C192.5 96 128 160.5 128 240C128 248 128.7 255.9 129.9 263.5C73 282.7 32 336.6 32 400z"
|
||||
const FIRE_PATH = "M256.5 37.6C265.8 29.8 279.5 30.1 288.4 38.5C300.7 50.1 311.7 62.9 322.3 75.9C335.8 92.4 352 114.2 367.6 140.1C372.8 133.3 377.6 127.3 381.8 122.2C382.9 120.9 384 119.5 385.1 118.1C393 108.3 402.8 96 415.9 96C429.3 96 438.7 107.9 446.7 118.1C448 119.8 449.3 121.4 450.6 122.9C460.9 135.3 474.6 153.2 488.3 175.3C515.5 219.2 543.9 281.7 543.9 351.9C543.9 475.6 443.6 575.9 319.9 575.9C196.2 575.9 96 475.7 96 352C96 260.9 137.1 182 176.5 127C196.4 99.3 216.2 77.1 231.1 61.9C239.3 53.5 247.6 45.2 256.6 37.7zM321.7 480C347 480 369.4 473 390.5 459C432.6 429.6 443.9 370.8 418.6 324.6C414.1 315.6 402.6 315 396.1 322.6L370.9 351.9C364.3 359.5 352.4 359.3 346.2 351.4C328.9 329.3 297.1 289 280.9 268.4C275.5 261.5 265.7 260.4 259.4 266.5C241.1 284.3 207.9 323.3 207.9 370.8C207.9 439.4 258.5 480 321.6 480z"
|
||||
function parseVec3String(str?: string) {
|
||||
if (!str || typeof str !== 'string') return { x: 0, y: 0, z: 0 };
|
||||
const [x, y, z] = str.split(',').map(s => Number(s.trim()));
|
||||
return {
|
||||
x: Number.isFinite(x) ? x : 0,
|
||||
y: Number.isFinite(y) ? y : 0,
|
||||
z: Number.isFinite(z) ? z : 0,
|
||||
};
|
||||
}
|
||||
function asNum(n: any, def=0) { const v = Number(n); return Number.isFinite(v) ? v : def }
|
||||
|
||||
// ---- Nade-Pfade & Effekte -------------------------------------------------
|
||||
const nadePathsRef = useRef<Map<string, NadePath>>(new Map())
|
||||
const [nadePaths, setNadePaths] = useState<NadePath[]>([])
|
||||
const syncNadePaths = () => setNadePaths(Array.from(nadePathsRef.current.values()))
|
||||
/* ───────────────── Types ───────────────── */
|
||||
type PlayerState = {
|
||||
id: string
|
||||
name?: string | null
|
||||
team?: 'T' | 'CT' | string
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
yaw?: number | null // Grad
|
||||
alive?: boolean
|
||||
}
|
||||
|
||||
const effectsRef = useRef<Map<string, Effect>>(new Map())
|
||||
const [effects, setEffects] = useState<Effect[]>([])
|
||||
const syncEffects = () => setEffects(Array.from(effectsRef.current.values()))
|
||||
type Grenade = {
|
||||
id: string
|
||||
kind: 'smoke' | 'molotov' | 'he' | 'flash' | 'decoy' | 'unknown'
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
radius?: number | null
|
||||
expiresAt?: number | null
|
||||
team?: 'T' | 'CT' | string | null
|
||||
}
|
||||
|
||||
// ---- Spieler (throttled) --------------------------------------------------
|
||||
type Overview = { posX: number; posY: number; scale: number; rotate?: number }
|
||||
type Mapper = (xw: number, yw: number) => { x: number; y: number }
|
||||
|
||||
/* ───────────────── Komponente ───────────────── */
|
||||
export default function LiveRadar() {
|
||||
const [wsStatus, setWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
|
||||
const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
|
||||
|
||||
// Spieler (throttled)
|
||||
const playersRef = useRef<Map<string, PlayerState>>(new Map())
|
||||
const [players, setPlayers] = useState<PlayerState[]>([])
|
||||
|
||||
// Grenades (throttled)
|
||||
const grenadesRef = useRef<Map<string, Grenade>>(new Map())
|
||||
const [grenades, setGrenades] = useState<Grenade[]>([])
|
||||
|
||||
// gemeinsamer Flush (Players + Grenades)
|
||||
const flushTimer = useRef<number | null>(null)
|
||||
const scheduleFlush = () => {
|
||||
if (flushTimer.current != null) return
|
||||
flushTimer.current = window.setTimeout(() => {
|
||||
flushTimer.current = null
|
||||
setPlayers(Array.from(playersRef.current.values()))
|
||||
setGrenades(Array.from(grenadesRef.current.values()))
|
||||
}, 66)
|
||||
}
|
||||
|
||||
// ---- MapVote laden --------------------------------------------------------
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
;(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
const r = await fetch(`/api/matches/${matchId}/mapvote`, { cache: 'no-store' })
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}))
|
||||
throw new Error(j?.message || 'Laden fehlgeschlagen')
|
||||
}
|
||||
const json = await r.json()
|
||||
if (!Array.isArray(json?.steps)) throw new Error('Ungültige Serverantwort (steps fehlt)')
|
||||
if (!cancel) setVoteData(json)
|
||||
} catch (e:any) {
|
||||
if (!cancel) setError(e?.message ?? 'Unbekannter Fehler')
|
||||
} finally {
|
||||
if (!cancel) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { cancel = true }
|
||||
}, [matchId])
|
||||
|
||||
// ---- Aktive Map bestimmen -------------------------------------------------
|
||||
const voteMapKey = useMemo(() => {
|
||||
const chosen = (voteData?.steps ?? []).filter(s => (s.action === 'pick' || s.action === 'decider') && s.map)
|
||||
return chosen[0]?.map ?? null
|
||||
}, [voteData])
|
||||
|
||||
const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!activeMapKey && voteMapKey) setActiveMapKey(voteMapKey)
|
||||
}, [voteMapKey, activeMapKey])
|
||||
|
||||
// ---- WebSocket verbinden --------------------------------------------------
|
||||
/* ───────────── WebSocket ───────────── */
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const explicit = process.env.NEXT_PUBLIC_CS2_WS_URL
|
||||
const host = process.env.NEXT_PUBLIC_CS2_WS_HOST || window.location.hostname
|
||||
const port = process.env.NEXT_PUBLIC_CS2_WS_PORT || '8081'
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const port = process.env.NEXT_PUBLIC_CS2_WS_PORT || ''
|
||||
const path = process.env.NEXT_PUBLIC_CS2_WS_PATH || '/telemetry'
|
||||
const url = explicit || `${proto}://${host}:${port}${path}`
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const portPart = port && port !== '80' && port !== '443' ? `:${port}` : ''
|
||||
const url = explicit || `${proto}://${host}${portPart}${path}`
|
||||
|
||||
let alive = true
|
||||
let ws: WebSocket | null = null
|
||||
let retry: number | null = null
|
||||
|
||||
// Effekte hinzufügen/entfernen
|
||||
const addEffect = (type: EffectType, m: any) => {
|
||||
const pos = m.pos ?? m.position
|
||||
const x = Number(m.x ?? pos?.x), y = Number(m.y ?? pos?.y), z = Number(m.z ?? pos?.z ?? 0)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
||||
const serverId = m.id ?? m.entityId ?? m.grenadeId ?? m.guid
|
||||
const id = String(serverId ?? `${type}:${Math.round(x)}:${Math.round(y)}:${Math.round(m.t ?? Date.now())}`)
|
||||
const ttlMs = type === 'smoke' ? 19000 : 7000
|
||||
effectsRef.current.set(id, { id, type, x, y, z, startMs: Date.now(), ttlMs })
|
||||
syncEffects()
|
||||
}
|
||||
|
||||
const removeEffect = (type: EffectType, m: any) => {
|
||||
const serverId = m.id ?? m.entityId ?? m.grenadeId ?? m.guid
|
||||
if (serverId != null) {
|
||||
const key = String(serverId)
|
||||
const e = effectsRef.current.get(key)
|
||||
if (e) {
|
||||
if (type === 'smoke') {
|
||||
e.ending = true
|
||||
e.fadeUntil = Date.now() + UI.effects.smokeFadeMs
|
||||
effectsRef.current.set(key, e)
|
||||
} else {
|
||||
effectsRef.current.delete(key)
|
||||
}
|
||||
syncEffects()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback: nächster gleicher Typ in der Nähe (ohne serverId)
|
||||
const pos = m.pos ?? m.position
|
||||
const x = Number(m.x ?? pos?.x), y = Number(m.y ?? pos?.y)
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) {
|
||||
let bestKey: string | null = null, bestD = Infinity
|
||||
for (const [k, e] of effectsRef.current) {
|
||||
if (e.type !== type) continue
|
||||
const d = Math.hypot(e.x - x, e.y - y)
|
||||
if (d < bestD) { bestD = d; bestKey = k }
|
||||
}
|
||||
if (bestKey && bestD < 200) {
|
||||
if (type === 'smoke') {
|
||||
const e = effectsRef.current.get(bestKey)!
|
||||
e.ending = true
|
||||
e.fadeUntil = Date.now() + UI.effects.smokeFadeMs
|
||||
effectsRef.current.set(bestKey, e)
|
||||
} else {
|
||||
effectsRef.current.delete(bestKey)
|
||||
}
|
||||
syncEffects()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nade-Trace verarbeiten
|
||||
const upsertNadeTrace = (tr: any) => {
|
||||
const now = Date.now()
|
||||
const id = String(tr?.id ?? tr?.guid ?? tr?.entityId ?? tr?.grenadeId ?? '')
|
||||
const kind = String(tr?.nade ?? tr?.kind ?? tr?.type ?? tr?.weapon ?? '').toLowerCase()
|
||||
const upsertPlayer = (e: any) => {
|
||||
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
|
||||
if (!id) return
|
||||
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates
|
||||
const x = Number(e.x ?? pos?.x ?? (Array.isArray(pos) ? pos?.[0] : undefined))
|
||||
const y = Number(e.y ?? pos?.y ?? (Array.isArray(pos) ? pos?.[1] : undefined))
|
||||
const z = Number(e.z ?? pos?.z ?? (Array.isArray(pos) ? pos?.[2] : 0))
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
||||
|
||||
const addPoint = (pt: any) => {
|
||||
const px = Number(pt?.x ?? pt?.pos?.x)
|
||||
const py = Number(pt?.y ?? pt?.pos?.y)
|
||||
const pz = Number(pt?.z ?? pt?.pos?.z ?? 0)
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py)) return
|
||||
const yaw = Number(
|
||||
e.yaw ??
|
||||
e.viewAngle?.yaw ??
|
||||
e.view?.yaw ??
|
||||
e.aim?.yaw ??
|
||||
e.ang?.y ??
|
||||
e.angles?.y ??
|
||||
e.rotation?.yaw
|
||||
)
|
||||
|
||||
const t = Number(pt?.t ?? tr?.t ?? now)
|
||||
const s0 = Number(pt?.s ?? pt?.S ?? tr?.s ?? tr?.S)
|
||||
|
||||
let cur = nadePathsRef.current.get(id)
|
||||
if (!cur) cur = { id, kind, points: [], startedMs: now }
|
||||
|
||||
const last = cur.points[cur.points.length - 1]
|
||||
const seg = Number.isFinite(s0) ? s0 : (last?.s ?? 0)
|
||||
|
||||
const tooClose = last && Math.hypot(last.x - px, last.y - py) <= 1
|
||||
const sameTime = last && (last.t ?? 0) === t
|
||||
if (tooClose && sameTime) return
|
||||
|
||||
cur.points.push({ x: px, y: py, z: pz, t, s: seg })
|
||||
nadePathsRef.current.set(id, cur)
|
||||
playersRef.current.set(id, {
|
||||
id,
|
||||
name: e.name ?? null,
|
||||
team: mapTeam(e.team),
|
||||
x, y, z,
|
||||
yaw: Number.isFinite(yaw) ? yaw : null,
|
||||
alive: e.alive,
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(tr?.points)) tr.points.forEach(addPoint)
|
||||
else if (tr?.pos || (tr?.x != null && tr?.y != null)) addPoint(tr.pos ?? tr)
|
||||
// >>> GSI-Zuschauer-Format verarbeiten
|
||||
const handleAllPlayers = (msg: any) => {
|
||||
const ap = msg?.allplayers
|
||||
if (!ap || typeof ap !== 'object') return
|
||||
for (const key of Object.keys(ap)) {
|
||||
const p = ap[key]
|
||||
const pos = parseVec3String(p.position) // "x, y, z" -> {x,y,z}
|
||||
const fwd = parseVec3String(p.forward)
|
||||
// yaw aus forward (x,y)
|
||||
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
|
||||
|
||||
const state = String(tr?.state ?? tr?.sub ?? tr?.phase ?? '').toLowerCase()
|
||||
const markEnded = () => {
|
||||
const cur = nadePathsRef.current.get(id)
|
||||
if (cur && !cur.endedMs) { cur.endedMs = now; nadePathsRef.current.set(id, cur) }
|
||||
const id = String(key) // in GSI-Snapshots ist das meist die Entität/Steam-ähnliche ID
|
||||
playersRef.current.set(id, {
|
||||
id,
|
||||
name: p.name ?? null,
|
||||
team: mapTeam(p.team),
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
z: pos.z,
|
||||
yaw,
|
||||
alive: p.state?.health > 0 || p.state?.health == null ? true : false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'detonate' || state === 'detonated' || tr?.detonate || tr?.done) {
|
||||
markEnded()
|
||||
const pos = tr?.pos ?? tr
|
||||
if (kind.includes('smoke')) addEffect('smoke', { ...pos, id })
|
||||
if (kind.includes('molotov') || kind.includes('incen') || kind.includes('fire')) addEffect('fire', { ...pos, id })
|
||||
// Grenades normalisieren (tolerant gegen versch. Formate)
|
||||
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
|
||||
const s = mapTeam(t)
|
||||
return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? t : null)
|
||||
}
|
||||
const normalizeGrenades = (raw: any): Grenade[] => {
|
||||
if (!raw) return []
|
||||
|
||||
// 1) Falls schon Array [{type, pos{x,y,z}, ...}]
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((g: any, i: number) => {
|
||||
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
|
||||
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)
|
||||
} as Grenade
|
||||
})
|
||||
}
|
||||
|
||||
if (state === 'end' || state === 'expired') {
|
||||
if (kind.includes('smoke')) removeEffect('smoke', { id })
|
||||
if (kind.includes('molotov') || kind.includes('incen') || kind.includes('fire')) removeEffect('fire', { id })
|
||||
markEnded()
|
||||
// 2) Objekt mit Buckets (smokes, flashbangs, ...)
|
||||
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'],
|
||||
}
|
||||
|
||||
const out: Grenade[] = []
|
||||
const push = (kind: Grenade['kind'], list: any) => {
|
||||
if (!list) return
|
||||
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 [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])
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Generischer Fallback: dict {typeKey -> items}
|
||||
if (out.length === 0 && typeof raw === 'object') {
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
const kk = k.toLowerCase()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const ingestGrenades = (g: any) => {
|
||||
const list = normalizeGrenades(g)
|
||||
const next = new Map<string, Grenade>()
|
||||
for (const it of list) next.set(it.id, it)
|
||||
grenadesRef.current = next
|
||||
}
|
||||
|
||||
const dispatch = (m: any) => {
|
||||
if (!m) return
|
||||
// Map aus verschiedenen Formaten abgreifen
|
||||
if (m.type === 'map' || m.type === 'level' || m.map) {
|
||||
const key = m.name || m.map || m.level || m.map?.name
|
||||
if (typeof key === 'string' && key) setActiveMapKey(key.toLowerCase())
|
||||
}
|
||||
// GSI Zuschauer-Format
|
||||
if (m.allplayers) handleAllPlayers(m)
|
||||
// Tick-Paket deines Servers
|
||||
if (m.type === 'tick') {
|
||||
if (typeof m.map === 'string' && m.map) setActiveMapKey(m.map.toLowerCase())
|
||||
if (Array.isArray(m.players)) for (const p of m.players) dispatch(p)
|
||||
if (m.grenades) ingestGrenades(m.grenades)
|
||||
}
|
||||
// Einzelspieler/Einzelevent
|
||||
if (m.steamId || m.steam_id || m.pos || m.position) upsertPlayer(m)
|
||||
// Grenades ggf. separat
|
||||
if (m.grenades && m.type !== 'tick') ingestGrenades(m.grenades)
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
@ -352,79 +306,19 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
ws = new WebSocket(url)
|
||||
|
||||
ws.onopen = () => setWsStatus('open')
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let msg: any = null
|
||||
try { msg = JSON.parse(ev.data as string) } catch {}
|
||||
|
||||
const handleEvent = (e: any) => {
|
||||
if (!e) return
|
||||
|
||||
// Map wechseln
|
||||
if (e.type === 'map' || e.type === 'level' || e.map) {
|
||||
const key = e.name || e.map || e.level
|
||||
if (typeof key === 'string' && key) setActiveMapKey(key)
|
||||
return
|
||||
}
|
||||
|
||||
// Effekte
|
||||
if (e.type === 'smoke' || e.type === 'fire') {
|
||||
const t = e.type as EffectType
|
||||
if (e.state === 'start') addEffect(t, e)
|
||||
else if (e.state === 'end') removeEffect(t, e)
|
||||
return
|
||||
}
|
||||
|
||||
// Grenade-Traces
|
||||
if (e.type === 'nade' || e.kind || e.nade || e.weapon) {
|
||||
upsertNadeTrace(e)
|
||||
}
|
||||
|
||||
// Spieler
|
||||
if (!(e.steamId || e.steam_id || e.pos || e.position)) return
|
||||
|
||||
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
|
||||
if (!id) return
|
||||
|
||||
const pos = e.pos ?? e.position ?? e.location ?? e.coordinates
|
||||
const x = Number(e.x ?? pos?.x ?? (Array.isArray(pos) ? pos?.[0] : undefined))
|
||||
const y = Number(e.y ?? pos?.y ?? (Array.isArray(pos) ? pos?.[1] : undefined))
|
||||
const z = Number(e.z ?? pos?.z ?? (Array.isArray(pos) ? pos?.[2] : 0))
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
||||
|
||||
const prev = playersRef.current.get(id)
|
||||
|
||||
const rawYaw =
|
||||
e.viewAngle?.yaw ??
|
||||
e.view?.yaw ??
|
||||
e.aim?.yaw ??
|
||||
e.yaw ??
|
||||
e.ang?.y ??
|
||||
e.angles?.y ??
|
||||
e.rotation?.yaw
|
||||
|
||||
const yawDegRaw = deriveYawDeg(rawYaw, prev, x, y)
|
||||
// Sanftes Smoothing, damit kleine Änderungen sichtbar & stabil sind
|
||||
const YAW_SMOOTH = 0.01
|
||||
const yawDeg = prev ? lerpAngleDeg(prev.yaw, yawDegRaw, YAW_SMOOTH) : yawDegRaw
|
||||
|
||||
const p: PlayerState = {
|
||||
id,
|
||||
name: e.name,
|
||||
team: mapTeam(e.team),
|
||||
x, y, z,
|
||||
yaw: yawDeg,
|
||||
alive: e.alive,
|
||||
}
|
||||
playersRef.current.set(id, p)
|
||||
}
|
||||
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
|
||||
|
||||
if (Array.isArray(msg)) {
|
||||
for (const e of msg) handleEvent(e)
|
||||
for (const e of msg) dispatch(e)
|
||||
} else if (msg?.type === 'tick' && Array.isArray(msg.players)) {
|
||||
for (const p of msg.players) handleEvent(p)
|
||||
} else {
|
||||
handleEvent(msg)
|
||||
if (typeof msg.map === 'string' && msg.map) setActiveMapKey(msg.map.toLowerCase())
|
||||
for (const p of msg.players) dispatch(p)
|
||||
if (msg.grenades) dispatch({ grenades: msg.grenades })
|
||||
} else if (msg) {
|
||||
if (msg?.map?.name && typeof msg.map.name === 'string') setActiveMapKey(msg.map.name.toLowerCase())
|
||||
dispatch(msg)
|
||||
}
|
||||
|
||||
scheduleFlush()
|
||||
@ -445,37 +339,9 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ---- Aufräumen (TTL) ------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
let changed = false
|
||||
let haveAny = false
|
||||
|
||||
for (const [k, e] of effectsRef.current) {
|
||||
haveAny = true
|
||||
if (e.ending && e.fadeUntil && now >= e.fadeUntil) {
|
||||
effectsRef.current.delete(k); changed = true; continue
|
||||
}
|
||||
if (!e.ending && now - e.startMs > e.ttlMs) {
|
||||
effectsRef.current.delete(k); changed = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const [k, np] of nadePathsRef.current) {
|
||||
if (np.endedMs && now - np.endedMs > NADE_PATH_TTL) {
|
||||
nadePathsRef.current.delete(k); changed = true
|
||||
}
|
||||
}
|
||||
if (changed) setNadePaths(Array.from(nadePathsRef.current.values()))
|
||||
if (changed || haveAny) setEffects(Array.from(effectsRef.current.values()))
|
||||
}, 100)
|
||||
return () => window.clearInterval(iv)
|
||||
}, [])
|
||||
|
||||
// ---- Overview laden -------------------------------------------------------
|
||||
/* ───────────── Overview laden ───────────── */
|
||||
const [overview, setOverview] = useState<Overview | null>(null)
|
||||
|
||||
const overviewCandidates = (mapKey: string) => {
|
||||
const base = mapKey
|
||||
return [
|
||||
@ -486,7 +352,6 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
`/assets/resource/overviews/${base}_s2.json`,
|
||||
]
|
||||
}
|
||||
|
||||
const parseOverviewJson = (j: any): Overview | null => {
|
||||
const posX = Number(j?.posX ?? j?.pos_x)
|
||||
const posY = Number(j?.posY ?? j?.pos_y)
|
||||
@ -495,7 +360,6 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
if (![posX, posY, scale].every(Number.isFinite)) return null
|
||||
return { posX, posY, scale, rotate }
|
||||
}
|
||||
|
||||
const parseValveKvOverview = (txt: string): Overview | null => {
|
||||
const clean = txt.replace(/\/\/.*$/gm, '')
|
||||
const pick = (k: string) => { const m = clean.match(new RegExp(`"\\s*${k}\\s*"\\s*"([^"]+)"`)); return m ? Number(m[1]) : NaN }
|
||||
@ -504,7 +368,6 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
if (![posX, posY, scale].every(Number.isFinite)) return null
|
||||
return { posX, posY, scale, rotate }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
;(async () => {
|
||||
@ -524,7 +387,7 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
return () => { cancel = true }
|
||||
}, [activeMapKey])
|
||||
|
||||
// ---- Radarbild-Pfade ------------------------------------------------------
|
||||
/* ───────────── Radarbild ───────────── */
|
||||
const { folderKey, imageCandidates } = useMemo(() => {
|
||||
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }
|
||||
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey
|
||||
@ -544,12 +407,25 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
useEffect(() => { setSrcIdx(0) }, [folderKey])
|
||||
const currentSrc = imageCandidates[srcIdx]
|
||||
|
||||
// ---- Bildgröße ------------------------------------------------------------
|
||||
const headerRef = useRef<HTMLDivElement | null>(null)
|
||||
const [maxImgHeight, setMaxImgHeight] = useState<number | null>(null)
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const bottom = headerRef.current?.getBoundingClientRect().bottom ?? 0
|
||||
setMaxImgHeight(Math.max(120, Math.floor(window.innerHeight - bottom - 16)))
|
||||
}
|
||||
update()
|
||||
window.addEventListener('resize', update)
|
||||
window.addEventListener('scroll', update, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener('resize', update)
|
||||
window.removeEventListener('scroll', update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null)
|
||||
|
||||
// ---- Welt→Pixel & Einheiten→Pixel ----------------------------------------
|
||||
type Mapper = (xw: number, yw: number) => { x: number; y: number }
|
||||
|
||||
/* ───────── Welt → Pixel ───────── */
|
||||
const worldToPx: Mapper = useMemo(() => {
|
||||
if (!imgSize || !overview) {
|
||||
return (xw, yw) => {
|
||||
@ -614,7 +490,7 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
return (u: number) => u * k
|
||||
}, [imgSize, overview])
|
||||
|
||||
// ---- Status-Badge ---------------------------------------------------------
|
||||
/* ───────── Status-Badge ───────── */
|
||||
const WsDot = ({ status }: { status: typeof wsStatus }) => {
|
||||
const color =
|
||||
status === 'open' ? 'bg-green-500' :
|
||||
@ -634,7 +510,7 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Render ---------------------------------------------------------------
|
||||
/* ───────── Render ───────── */
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div ref={headerRef} className="mb-4 flex items-center justify-between">
|
||||
@ -642,19 +518,14 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm opacity-80">
|
||||
{activeMapKey
|
||||
? (voteData?.mapVisuals?.[activeMapKey]?.label ??
|
||||
activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase())
|
||||
? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase()
|
||||
: '—'}
|
||||
</div>
|
||||
<WsDot status={wsStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 flex justify-center"><LoadingSpinner /></div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-red-600">{error}</div>
|
||||
) : !activeMapKey ? (
|
||||
{!activeMapKey ? (
|
||||
<div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
|
||||
Keine Map erkannt.
|
||||
</div>
|
||||
@ -678,7 +549,6 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
}}
|
||||
onError={() => {
|
||||
if (srcIdx < imageCandidates.length - 1) setSrcIdx(i => i + 1)
|
||||
else setError('Radar-Grafik nicht gefunden.')
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -688,53 +558,66 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
{/* SVG-Defs */}
|
||||
<defs>
|
||||
<filter id="smoke-blur" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="8" />
|
||||
</filter>
|
||||
<filter id="fire-blur" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" />
|
||||
</filter>
|
||||
<radialGradient id="fire-grad" cx="50%" cy="40%" r="60%">
|
||||
<stop offset="0%" stopColor="#ffd166" stopOpacity="0.95" />
|
||||
<stop offset="60%" stopColor="#f97316" stopOpacity="0.7" />
|
||||
<stop offset="100%" stopColor="#ef4444" stopOpacity="0.0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
{/* ───── Grenades layer (unter Spielern) ───── */}
|
||||
{grenades.map((g) => {
|
||||
const P = worldToPx(g.x, g.y)
|
||||
// typische Radien (world units), falls Server nichts liefert
|
||||
const defaultRadius =
|
||||
g.kind === 'smoke' ? 150 :
|
||||
g.kind === 'molotov'? 120 :
|
||||
g.kind === 'he' ? 40 :
|
||||
g.kind === 'flash' ? 36 :
|
||||
g.kind === 'decoy' ? 80 : 60
|
||||
|
||||
{/* Effekte */}
|
||||
{effects.map(e => {
|
||||
const { x, y } = worldToPx(e.x, e.y)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
|
||||
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? defaultRadius))
|
||||
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)
|
||||
|
||||
const R_WORLD = e.type === 'smoke' ? 170 : 110
|
||||
const halfPx = Math.max(12, unitsToPx(R_WORLD))
|
||||
const sBase = (halfPx * 2) / 640
|
||||
const s = sBase * (e.type === 'smoke' ? UI.effects.smokeIconScale : UI.effects.fireIconScale)
|
||||
const baseT = `translate(${x},${y}) scale(${s}) translate(-320,-320)`
|
||||
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
||||
|
||||
let fadeAlpha = 1
|
||||
if (e.type === 'smoke' && e.fadeUntil) {
|
||||
const remain = Math.max(0, e.fadeUntil - Date.now())
|
||||
fadeAlpha = Math.min(1, remain / UI.effects.smokeFadeMs)
|
||||
}
|
||||
|
||||
if (e.type === 'smoke') {
|
||||
if (g.kind === 'smoke') {
|
||||
return (
|
||||
<g filter="url(#smoke-blur)" opacity={UI.effects.smokeOpacity * fadeAlpha} transform={baseT}>
|
||||
<path d={SMOKE_PATH} fill="#949494" fillOpacity={UI.effects.smokeFillOpacity} />
|
||||
<g key={g.id}>
|
||||
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
if (g.kind === 'molotov') {
|
||||
return (
|
||||
<g filter="url(#fire-blur)" opacity={UI.effects.fireOpacity} transform={baseT}>
|
||||
<path d={FIRE_PATH} fill="url(#fire-grad)" />
|
||||
<g key={g.id}>
|
||||
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.fireFill} stroke={stroke} strokeWidth={sw} />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
if (g.kind === 'decoy') {
|
||||
return (
|
||||
<g key={g.id}>
|
||||
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.decoyFill} stroke={stroke} strokeWidth={sw} strokeDasharray="6,4" />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
if (g.kind === 'flash') {
|
||||
// kleiner Ring + Kreuz
|
||||
return (
|
||||
<g key={g.id}>
|
||||
<circle cx={P.x} cy={P.y} r={rPx*0.6} fill="none" stroke={stroke} strokeWidth={sw} />
|
||||
<circle cx={P.x} cy={P.y} r={Math.max(2, rPx*0.25)} fill={UI.nade.flashFill} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
|
||||
<line x1={P.x-rPx*0.9} y1={P.y} x2={P.x+rPx*0.9} y2={P.y} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
|
||||
<line x1={P.x} y1={P.y-rPx*0.9} x2={P.x} y2={P.y+rPx*0.9} stroke={stroke} strokeWidth={Math.max(1, sw*0.6)} strokeLinecap="round"/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
// HE + unknown: kompakter Punkt
|
||||
return (
|
||||
<g key={g.id}>
|
||||
<circle 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)} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Spieler */}
|
||||
{/* ───── Spieler layer ───── */}
|
||||
{players
|
||||
.filter(p => p.team === 'CT' || p.team === 'T')
|
||||
.map((p) => {
|
||||
@ -748,79 +631,40 @@ export default function LiveRadar({ matchId }: Props) {
|
||||
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
|
||||
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
|
||||
|
||||
const yawRad = Number.isFinite(p.yaw) ? (p.yaw * Math.PI) / 180 : 0
|
||||
|
||||
// Richtung als Welt-Schritt (respektiert Overview-Rotation)
|
||||
// Blickrichtung aus yaw (Grad)
|
||||
let dxp = 0, dyp = 0
|
||||
if (Number.isFinite(p.yaw as number)) {
|
||||
const yawRad = (Number(p.yaw) * Math.PI) / 180
|
||||
const STEP_WORLD = 200
|
||||
const B = worldToPx(
|
||||
p.x + Math.cos(yawRad) * STEP_WORLD,
|
||||
p.y + Math.sin(yawRad) * STEP_WORLD
|
||||
)
|
||||
|
||||
let dxp = B.x - A.x, dyp = B.y - A.y
|
||||
if (!Number.isFinite(dxp) || !Number.isFinite(dyp)) { dxp = STEP_WORLD; dyp = 0 }
|
||||
|
||||
dxp = B.x - A.x
|
||||
dyp = B.y - A.y
|
||||
const cur = Math.hypot(dxp, dyp) || 1
|
||||
dxp *= dirLenPx / cur
|
||||
dyp *= dirLenPx / cur
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={p.id}>
|
||||
{/* Kreis zuerst */}
|
||||
<circle
|
||||
cx={A.x} cy={A.y} r={r}
|
||||
fill={fillColor} stroke={stroke}
|
||||
strokeWidth={Math.max(1, r*0.3)}
|
||||
opacity={p.alive === false ? 0.6 : 1}
|
||||
/>
|
||||
{/* Linie darüber (sichtbar) */}
|
||||
{Number.isFinite(p.yaw as number) && (
|
||||
<line
|
||||
x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp}
|
||||
stroke={dirColor} strokeWidth={strokeW} strokeLinecap="round"
|
||||
opacity={p.alive === false ? 0.5 : 1}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Nade-Pfade */}
|
||||
<g opacity={0.95}>
|
||||
{nadePaths.map(np => {
|
||||
const col = nadeColor(np.kind)
|
||||
const wBase = Math.min(imgSize.w, imgSize.h)
|
||||
const strokeW = Math.max(2, wBase * 0.004)
|
||||
const dotR = Math.max(1.5, wBase * 0.0025)
|
||||
let alpha = 1
|
||||
if (np.endedMs) alpha = Math.max(0, 1 - (Date.now() - np.endedMs) / NADE_PATH_TTL)
|
||||
|
||||
const pts = np.points
|
||||
.map(p => worldToPx(p.x, p.y))
|
||||
.filter(p => Number.isFinite(p.x) && Number.isFinite(p.y))
|
||||
if (pts.length === 0) return null
|
||||
|
||||
const d = pts.length >= 2
|
||||
? `M ${pts[0].x} ${pts[0].y} ` + pts.slice(1).map(p => `L ${p.x} ${p.y}`).join(' ')
|
||||
: null
|
||||
|
||||
return (
|
||||
<g key={np.id} opacity={alpha}>
|
||||
{d && (
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={col}
|
||||
strokeWidth={strokeW}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
{pts.map((p, i) => (
|
||||
<circle key={`${np.id}:pt:${i}`} cx={p.x} cy={p.y} r={dotR} fill={col} opacity={0.9} />
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -3,21 +3,30 @@ import { headers } from 'next/headers'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { MatchProvider } from './MatchContext'
|
||||
import type { Match } from '@/app/types/match'
|
||||
import https from 'https'
|
||||
import { Agent } from 'undici'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const revalidate = 0
|
||||
// (optional) falls du sicher Node Runtime willst:
|
||||
// export const runtime = 'nodejs'
|
||||
|
||||
|
||||
async function loadMatch(matchId: string): Promise<Match | null> {
|
||||
const h = await headers(); // ⬅️ wichtig
|
||||
const h = await headers()
|
||||
const proto = (h.get('x-forwarded-proto') ?? 'http').split(',')[0].trim()
|
||||
const host = (h.get('x-forwarded-host') ?? h.get('host') ?? '').split(',')[0].trim()
|
||||
const base = host ? `${proto}://${host}` : (process.env.NEXTAUTH_URL ?? 'http://localhost:3000')
|
||||
|
||||
// Fallback, falls in seltenen Fällen kein Host vorhanden ist (z. B. bei lokalen Tests)
|
||||
const base = host ? `${proto}://${host}` : (process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000')
|
||||
// ⚠️ Nur in Dev benutzen!
|
||||
const insecure = new Agent({ connect: { rejectUnauthorized: false } })
|
||||
|
||||
const res = await fetch(`${base}/api/matches/${matchId}`, { cache: 'no-store' })
|
||||
const init: any = { cache: 'no-store' }
|
||||
if (base.startsWith('https://') && process.env.NODE_ENV !== 'production') {
|
||||
init.dispatcher = insecure
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/matches/${matchId}`, init)
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user