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_SECRET_KEY=6f9d4a2951b8eae35cdd3fb28e1a74550d177c3900ad1111c8e48b4e3b39bba4
|
||||||
SHARE_CODE_IV=9f1d67b8a3c4d261fa2b7c44a1d4f9c8
|
SHARE_CODE_IV=9f1d67b8a3c4d261fa2b7c44a1d4f9c8
|
||||||
STEAM_API_KEY=0B3B2BF79ECD1E9262BB118A7FEF1973
|
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_SECRET=ironieopen
|
||||||
NEXTAUTH_URL=https://ironieopen.local
|
NEXTAUTH_URL=https://ironieopen.local
|
||||||
AUTH_SECRET="57AUHXa+UmFrlnIEKxtrk8fLo+aZMtsa/oV6fklXkcE=" # Added by `npx auth`. Read more: https://cli.authjs.dev
|
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_USER=army.37a11489
|
||||||
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
|
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
|
||||||
PTERO_SERVER_ID=37a11489
|
PTERO_SERVER_ID=37a11489
|
||||||
NEXT_PUBLIC_CS2_WS_URL=wss://ws.ironieopen.de:8081/telemetry
|
NEXT_PUBLIC_CS2_WS_HOST=ironieopen.local
|
||||||
NEXT_PUBLIC_CS2_WS_HOST=ws.ironieopen.de
|
NEXT_PUBLIC_CS2_WS_PORT=443
|
||||||
NEXT_PUBLIC_CS2_WS_PORT=8081
|
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-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
|
"undici": "^7.15.0",
|
||||||
"vanilla-calendar-pro": "^3.0.4",
|
"vanilla-calendar-pro": "^3.0.4",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
@ -7957,6 +7958,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.19.8",
|
"version": "6.19.8",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||||
|
|||||||
@ -49,6 +49,7 @@
|
|||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"ssh2-sftp-client": "^12.0.1",
|
"ssh2-sftp-client": "^12.0.1",
|
||||||
|
"undici": "^7.15.0",
|
||||||
"vanilla-calendar-pro": "^3.0.4",
|
"vanilla-calendar-pro": "^3.0.4",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,133 +2,35 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import LoadingSpinner from './LoadingSpinner'
|
|
||||||
|
|
||||||
// ---------- Konfiguration (UI & Verhalten) ----------
|
/* ───────────────── UI ───────────────── */
|
||||||
const UI = {
|
const UI = {
|
||||||
player: {
|
player: {
|
||||||
minRadiusPx: 4,
|
minRadiusPx: 4,
|
||||||
radiusRel: 0.008, // Radius relativ zur kleineren Bildkante
|
radiusRel: 0.008, // relativ zur kleineren Bildkante
|
||||||
dirLenRel: 0.70, // Anteil des Radius, Linie bleibt im Kreis
|
dirLenRel: 0.70, // Anteil des Radius
|
||||||
dirMinLenPx: 6,
|
dirMinLenPx: 6,
|
||||||
lineWidthRel: 0.25, // Linienbreite relativ zum Radius
|
lineWidthRel: 0.25,
|
||||||
stroke: '#ffffff',
|
stroke: '#ffffff',
|
||||||
fillCT: '#3b82f6',
|
fillCT: '#3b82f6',
|
||||||
fillT: '#f59e0b',
|
fillT: '#f59e0b',
|
||||||
// 'auto' = automatisch kontrastierend zum Kreis, sonst fixe Farbe wie '#fff'
|
dirColor: 'auto' as 'auto' | string, // 'auto' = Kontrast zum Kreis
|
||||||
dirColor: 'auto' as 'auto' | string,
|
|
||||||
},
|
},
|
||||||
effects: {
|
|
||||||
smokeIconScale: 1.6,
|
/* ───────────────── UI (Grenades) ───────────────── */
|
||||||
fireIconScale: 1.45,
|
nade: {
|
||||||
smokeOpacity: 0.95,
|
stroke: '#111111',
|
||||||
smokeFillOpacity: 0.70,
|
smokeFill: 'rgba(160,160,160,0.35)',
|
||||||
fireOpacity: 1,
|
fireFill: 'rgba(255,128,0,0.35)',
|
||||||
smokeFadeMs: 3000,
|
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) {
|
function contrastStroke(hex: string) {
|
||||||
const h = hex.replace('#','')
|
const h = hex.replace('#','')
|
||||||
const r = parseInt(h.slice(0,2),16)/255
|
const r = parseInt(h.slice(0,2),16)/255
|
||||||
@ -139,211 +41,263 @@ function contrastStroke(hex: string) {
|
|||||||
return L > 0.6 ? '#111111' : '#ffffff'
|
return L > 0.6 ? '#111111' : '#ffffff'
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LiveRadar({ matchId }: Props) {
|
function mapTeam(t: any): 'T' | 'CT' | string {
|
||||||
// ---- MapVote (Backup)
|
if (t === 2 || t === 'T' || t === 't') return 'T'
|
||||||
const [loading, setLoading] = useState(true)
|
if (t === 3 || t === 'CT' || t === 'ct') return 'CT'
|
||||||
const [error, setError] = useState<string | null>(null)
|
return String(t ?? '')
|
||||||
const [voteData, setVoteData] = useState<ApiResponse | null>(null)
|
}
|
||||||
|
|
||||||
// ---- WS
|
const RAD2DEG = 180 / Math.PI;
|
||||||
const [wsStatus, setWsStatus] =
|
|
||||||
useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
|
|
||||||
|
|
||||||
// ---- Layout: Bild maximal Viewport-Höhe
|
function normalizeDeg(d: number) {
|
||||||
const headerRef = useRef<HTMLDivElement | null>(null)
|
d = d % 360;
|
||||||
const [maxImgHeight, setMaxImgHeight] = useState<number | null>(null)
|
return d < 0 ? d + 360 : d;
|
||||||
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)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// ---- Effekt-SVG-Paths
|
function parseVec3String(str?: string) {
|
||||||
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"
|
if (!str || typeof str !== 'string') return { x: 0, y: 0, z: 0 };
|
||||||
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"
|
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 -------------------------------------------------
|
/* ───────────────── Types ───────────────── */
|
||||||
const nadePathsRef = useRef<Map<string, NadePath>>(new Map())
|
type PlayerState = {
|
||||||
const [nadePaths, setNadePaths] = useState<NadePath[]>([])
|
id: string
|
||||||
const syncNadePaths = () => setNadePaths(Array.from(nadePathsRef.current.values()))
|
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())
|
type Grenade = {
|
||||||
const [effects, setEffects] = useState<Effect[]>([])
|
id: string
|
||||||
const syncEffects = () => setEffects(Array.from(effectsRef.current.values()))
|
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 playersRef = useRef<Map<string, PlayerState>>(new Map())
|
||||||
const [players, setPlayers] = useState<PlayerState[]>([])
|
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 flushTimer = useRef<number | null>(null)
|
||||||
const scheduleFlush = () => {
|
const scheduleFlush = () => {
|
||||||
if (flushTimer.current != null) return
|
if (flushTimer.current != null) return
|
||||||
flushTimer.current = window.setTimeout(() => {
|
flushTimer.current = window.setTimeout(() => {
|
||||||
flushTimer.current = null
|
flushTimer.current = null
|
||||||
setPlayers(Array.from(playersRef.current.values()))
|
setPlayers(Array.from(playersRef.current.values()))
|
||||||
|
setGrenades(Array.from(grenadesRef.current.values()))
|
||||||
}, 66)
|
}, 66)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- MapVote laden --------------------------------------------------------
|
/* ───────────── WebSocket ───────────── */
|
||||||
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 --------------------------------------------------
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
const explicit = process.env.NEXT_PUBLIC_CS2_WS_URL
|
const explicit = process.env.NEXT_PUBLIC_CS2_WS_URL
|
||||||
const host = process.env.NEXT_PUBLIC_CS2_WS_HOST || window.location.hostname
|
const host = process.env.NEXT_PUBLIC_CS2_WS_HOST || window.location.hostname
|
||||||
const port = process.env.NEXT_PUBLIC_CS2_WS_PORT || '8081'
|
const port = process.env.NEXT_PUBLIC_CS2_WS_PORT || ''
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
|
||||||
const path = process.env.NEXT_PUBLIC_CS2_WS_PATH || '/telemetry'
|
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 alive = true
|
||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
let retry: number | null = null
|
let retry: number | null = null
|
||||||
|
|
||||||
// Effekte hinzufügen/entfernen
|
const upsertPlayer = (e: any) => {
|
||||||
const addEffect = (type: EffectType, m: any) => {
|
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
|
||||||
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()
|
|
||||||
if (!id) return
|
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 yaw = Number(
|
||||||
const px = Number(pt?.x ?? pt?.pos?.x)
|
e.yaw ??
|
||||||
const py = Number(pt?.y ?? pt?.pos?.y)
|
e.viewAngle?.yaw ??
|
||||||
const pz = Number(pt?.z ?? pt?.pos?.z ?? 0)
|
e.view?.yaw ??
|
||||||
if (!Number.isFinite(px) || !Number.isFinite(py)) return
|
e.aim?.yaw ??
|
||||||
|
e.ang?.y ??
|
||||||
|
e.angles?.y ??
|
||||||
|
e.rotation?.yaw
|
||||||
|
)
|
||||||
|
|
||||||
const t = Number(pt?.t ?? tr?.t ?? now)
|
playersRef.current.set(id, {
|
||||||
const s0 = Number(pt?.s ?? pt?.S ?? tr?.s ?? tr?.S)
|
id,
|
||||||
|
name: e.name ?? null,
|
||||||
let cur = nadePathsRef.current.get(id)
|
team: mapTeam(e.team),
|
||||||
if (!cur) cur = { id, kind, points: [], startedMs: now }
|
x, y, z,
|
||||||
|
yaw: Number.isFinite(yaw) ? yaw : null,
|
||||||
const last = cur.points[cur.points.length - 1]
|
alive: e.alive,
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(tr?.points)) tr.points.forEach(addPoint)
|
// >>> GSI-Zuschauer-Format verarbeiten
|
||||||
else if (tr?.pos || (tr?.x != null && tr?.y != null)) addPoint(tr.pos ?? tr)
|
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 id = String(key) // in GSI-Snapshots ist das meist die Entität/Steam-ähnliche ID
|
||||||
const markEnded = () => {
|
playersRef.current.set(id, {
|
||||||
const cur = nadePathsRef.current.get(id)
|
id,
|
||||||
if (cur && !cur.endedMs) { cur.endedMs = now; nadePathsRef.current.set(id, cur) }
|
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) {
|
// Grenades normalisieren (tolerant gegen versch. Formate)
|
||||||
markEnded()
|
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
|
||||||
const pos = tr?.pos ?? tr
|
const s = mapTeam(t)
|
||||||
if (kind.includes('smoke')) addEffect('smoke', { ...pos, id })
|
return s === 'T' || s === 'CT' ? s : (typeof t === 'string' ? t : null)
|
||||||
if (kind.includes('molotov') || kind.includes('incen') || kind.includes('fire')) addEffect('fire', { ...pos, id })
|
}
|
||||||
|
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') {
|
// 2) Objekt mit Buckets (smokes, flashbangs, ...)
|
||||||
if (kind.includes('smoke')) removeEffect('smoke', { id })
|
const buckets: Record<string, string[]> = {
|
||||||
if (kind.includes('molotov') || kind.includes('incen') || kind.includes('fire')) removeEffect('fire', { id })
|
smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'],
|
||||||
markEnded()
|
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 = () => {
|
const connect = () => {
|
||||||
@ -352,79 +306,19 @@ export default function LiveRadar({ matchId }: Props) {
|
|||||||
ws = new WebSocket(url)
|
ws = new WebSocket(url)
|
||||||
|
|
||||||
ws.onopen = () => setWsStatus('open')
|
ws.onopen = () => setWsStatus('open')
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
ws.onmessage = (ev) => {
|
||||||
let msg: any = null
|
let msg: any = null
|
||||||
try { msg = JSON.parse(ev.data as string) } catch {}
|
try { msg = JSON.parse(String(ev.data ?? '')) } 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(msg)) {
|
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)) {
|
} else if (msg?.type === 'tick' && Array.isArray(msg.players)) {
|
||||||
for (const p of msg.players) handleEvent(p)
|
if (typeof msg.map === 'string' && msg.map) setActiveMapKey(msg.map.toLowerCase())
|
||||||
} else {
|
for (const p of msg.players) dispatch(p)
|
||||||
handleEvent(msg)
|
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()
|
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) {
|
/* ───────────── Overview laden ───────────── */
|
||||||
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 -------------------------------------------------------
|
|
||||||
const [overview, setOverview] = useState<Overview | null>(null)
|
const [overview, setOverview] = useState<Overview | null>(null)
|
||||||
|
|
||||||
const overviewCandidates = (mapKey: string) => {
|
const overviewCandidates = (mapKey: string) => {
|
||||||
const base = mapKey
|
const base = mapKey
|
||||||
return [
|
return [
|
||||||
@ -486,7 +352,6 @@ export default function LiveRadar({ matchId }: Props) {
|
|||||||
`/assets/resource/overviews/${base}_s2.json`,
|
`/assets/resource/overviews/${base}_s2.json`,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseOverviewJson = (j: any): Overview | null => {
|
const parseOverviewJson = (j: any): Overview | null => {
|
||||||
const posX = Number(j?.posX ?? j?.pos_x)
|
const posX = Number(j?.posX ?? j?.pos_x)
|
||||||
const posY = Number(j?.posY ?? j?.pos_y)
|
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
|
if (![posX, posY, scale].every(Number.isFinite)) return null
|
||||||
return { posX, posY, scale, rotate }
|
return { posX, posY, scale, rotate }
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseValveKvOverview = (txt: string): Overview | null => {
|
const parseValveKvOverview = (txt: string): Overview | null => {
|
||||||
const clean = txt.replace(/\/\/.*$/gm, '')
|
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 }
|
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
|
if (![posX, posY, scale].every(Number.isFinite)) return null
|
||||||
return { posX, posY, scale, rotate }
|
return { posX, posY, scale, rotate }
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancel = false
|
let cancel = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
@ -524,7 +387,7 @@ export default function LiveRadar({ matchId }: Props) {
|
|||||||
return () => { cancel = true }
|
return () => { cancel = true }
|
||||||
}, [activeMapKey])
|
}, [activeMapKey])
|
||||||
|
|
||||||
// ---- Radarbild-Pfade ------------------------------------------------------
|
/* ───────────── Radarbild ───────────── */
|
||||||
const { folderKey, imageCandidates } = useMemo(() => {
|
const { folderKey, imageCandidates } = useMemo(() => {
|
||||||
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }
|
if (!activeMapKey) return { folderKey: null as string | null, imageCandidates: [] as string[] }
|
||||||
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey
|
const short = activeMapKey.startsWith('de_') ? activeMapKey.slice(3) : activeMapKey
|
||||||
@ -544,12 +407,25 @@ export default function LiveRadar({ matchId }: Props) {
|
|||||||
useEffect(() => { setSrcIdx(0) }, [folderKey])
|
useEffect(() => { setSrcIdx(0) }, [folderKey])
|
||||||
const currentSrc = imageCandidates[srcIdx]
|
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)
|
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null)
|
||||||
|
|
||||||
// ---- Welt→Pixel & Einheiten→Pixel ----------------------------------------
|
/* ───────── Welt → Pixel ───────── */
|
||||||
type Mapper = (xw: number, yw: number) => { x: number; y: number }
|
|
||||||
|
|
||||||
const worldToPx: Mapper = useMemo(() => {
|
const worldToPx: Mapper = useMemo(() => {
|
||||||
if (!imgSize || !overview) {
|
if (!imgSize || !overview) {
|
||||||
return (xw, yw) => {
|
return (xw, yw) => {
|
||||||
@ -614,7 +490,7 @@ export default function LiveRadar({ matchId }: Props) {
|
|||||||
return (u: number) => u * k
|
return (u: number) => u * k
|
||||||
}, [imgSize, overview])
|
}, [imgSize, overview])
|
||||||
|
|
||||||
// ---- Status-Badge ---------------------------------------------------------
|
/* ───────── Status-Badge ───────── */
|
||||||
const WsDot = ({ status }: { status: typeof wsStatus }) => {
|
const WsDot = ({ status }: { status: typeof wsStatus }) => {
|
||||||
const color =
|
const color =
|
||||||
status === 'open' ? 'bg-green-500' :
|
status === 'open' ? 'bg-green-500' :
|
||||||
@ -634,7 +510,7 @@ export default function LiveRadar({ matchId }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Render ---------------------------------------------------------------
|
/* ───────── Render ───────── */
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div ref={headerRef} className="mb-4 flex items-center justify-between">
|
<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="flex items-center gap-3">
|
||||||
<div className="text-sm opacity-80">
|
<div className="text-sm opacity-80">
|
||||||
{activeMapKey
|
{activeMapKey
|
||||||
? (voteData?.mapVisuals?.[activeMapKey]?.label ??
|
? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase()
|
||||||
activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase())
|
|
||||||
: '—'}
|
: '—'}
|
||||||
</div>
|
</div>
|
||||||
<WsDot status={wsStatus} />
|
<WsDot status={wsStatus} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{!activeMapKey ? (
|
||||||
<div className="p-8 flex justify-center"><LoadingSpinner /></div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="p-4 text-red-600">{error}</div>
|
|
||||||
) : !activeMapKey ? (
|
|
||||||
<div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
|
<div className="p-4 rounded bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-100">
|
||||||
Keine Map erkannt.
|
Keine Map erkannt.
|
||||||
</div>
|
</div>
|
||||||
@ -678,7 +549,6 @@ export default function LiveRadar({ matchId }: Props) {
|
|||||||
}}
|
}}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
if (srcIdx < imageCandidates.length - 1) setSrcIdx(i => i + 1)
|
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}`}
|
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
|
||||||
preserveAspectRatio="xMidYMid meet"
|
preserveAspectRatio="xMidYMid meet"
|
||||||
>
|
>
|
||||||
{/* SVG-Defs */}
|
{/* ───── Grenades layer (unter Spielern) ───── */}
|
||||||
<defs>
|
{grenades.map((g) => {
|
||||||
<filter id="smoke-blur" x="-50%" y="-50%" width="200%" height="200%">
|
const P = worldToPx(g.x, g.y)
|
||||||
<feGaussianBlur stdDeviation="8" />
|
// typische Radien (world units), falls Server nichts liefert
|
||||||
</filter>
|
const defaultRadius =
|
||||||
<filter id="fire-blur" x="-50%" y="-50%" width="200%" height="200%">
|
g.kind === 'smoke' ? 150 :
|
||||||
<feGaussianBlur stdDeviation="3" />
|
g.kind === 'molotov'? 120 :
|
||||||
</filter>
|
g.kind === 'he' ? 40 :
|
||||||
<radialGradient id="fire-grad" cx="50%" cy="40%" r="60%">
|
g.kind === 'flash' ? 36 :
|
||||||
<stop offset="0%" stopColor="#ffd166" stopOpacity="0.95" />
|
g.kind === 'decoy' ? 80 : 60
|
||||||
<stop offset="60%" stopColor="#f97316" stopOpacity="0.7" />
|
|
||||||
<stop offset="100%" stopColor="#ef4444" stopOpacity="0.0" />
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* Effekte */}
|
const rPx = Math.max(UI.nade.minRadiusPx, unitsToPx(g.radius ?? defaultRadius))
|
||||||
{effects.map(e => {
|
const stroke = g.team === 'CT' ? UI.nade.teamStrokeCT
|
||||||
const { x, y } = worldToPx(e.x, e.y)
|
: g.team === 'T' ? UI.nade.teamStrokeT
|
||||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
|
: UI.nade.stroke
|
||||||
|
const sw = Math.max(1, Math.sqrt(rPx) * 0.6)
|
||||||
|
|
||||||
const R_WORLD = e.type === 'smoke' ? 170 : 110
|
if (!Number.isFinite(P.x) || !Number.isFinite(P.y)) return null
|
||||||
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)`
|
|
||||||
|
|
||||||
let fadeAlpha = 1
|
if (g.kind === 'smoke') {
|
||||||
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') {
|
|
||||||
return (
|
return (
|
||||||
<g filter="url(#smoke-blur)" opacity={UI.effects.smokeOpacity * fadeAlpha} transform={baseT}>
|
<g key={g.id}>
|
||||||
<path d={SMOKE_PATH} fill="#949494" fillOpacity={UI.effects.smokeFillOpacity} />
|
<circle cx={P.x} cy={P.y} r={rPx} fill={UI.nade.smokeFill} stroke={stroke} strokeWidth={sw} />
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (g.kind === 'molotov') {
|
||||||
return (
|
return (
|
||||||
<g filter="url(#fire-blur)" opacity={UI.effects.fireOpacity} transform={baseT}>
|
<g key={g.id}>
|
||||||
<path d={FIRE_PATH} fill="url(#fire-grad)" />
|
<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>
|
</g>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Spieler */}
|
{/* ───── Spieler layer ───── */}
|
||||||
{players
|
{players
|
||||||
.filter(p => p.team === 'CT' || p.team === 'T')
|
.filter(p => p.team === 'CT' || p.team === 'T')
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
@ -748,79 +631,40 @@ export default function LiveRadar({ matchId }: Props) {
|
|||||||
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
|
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
|
||||||
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
|
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
|
||||||
|
|
||||||
const yawRad = Number.isFinite(p.yaw) ? (p.yaw * Math.PI) / 180 : 0
|
// Blickrichtung aus yaw (Grad)
|
||||||
|
let dxp = 0, dyp = 0
|
||||||
// Richtung als Welt-Schritt (respektiert Overview-Rotation)
|
if (Number.isFinite(p.yaw as number)) {
|
||||||
|
const yawRad = (Number(p.yaw) * Math.PI) / 180
|
||||||
const STEP_WORLD = 200
|
const STEP_WORLD = 200
|
||||||
const B = worldToPx(
|
const B = worldToPx(
|
||||||
p.x + Math.cos(yawRad) * STEP_WORLD,
|
p.x + Math.cos(yawRad) * STEP_WORLD,
|
||||||
p.y + Math.sin(yawRad) * STEP_WORLD
|
p.y + Math.sin(yawRad) * STEP_WORLD
|
||||||
)
|
)
|
||||||
|
dxp = B.x - A.x
|
||||||
let dxp = B.x - A.x, dyp = B.y - A.y
|
dyp = B.y - A.y
|
||||||
if (!Number.isFinite(dxp) || !Number.isFinite(dyp)) { dxp = STEP_WORLD; dyp = 0 }
|
|
||||||
|
|
||||||
const cur = Math.hypot(dxp, dyp) || 1
|
const cur = Math.hypot(dxp, dyp) || 1
|
||||||
dxp *= dirLenPx / cur
|
dxp *= dirLenPx / cur
|
||||||
dyp *= dirLenPx / cur
|
dyp *= dirLenPx / cur
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={p.id}>
|
<g key={p.id}>
|
||||||
{/* Kreis zuerst */}
|
|
||||||
<circle
|
<circle
|
||||||
cx={A.x} cy={A.y} r={r}
|
cx={A.x} cy={A.y} r={r}
|
||||||
fill={fillColor} stroke={stroke}
|
fill={fillColor} stroke={stroke}
|
||||||
strokeWidth={Math.max(1, r*0.3)}
|
strokeWidth={Math.max(1, r*0.3)}
|
||||||
opacity={p.alive === false ? 0.6 : 1}
|
opacity={p.alive === false ? 0.6 : 1}
|
||||||
/>
|
/>
|
||||||
{/* Linie darüber (sichtbar) */}
|
{Number.isFinite(p.yaw as number) && (
|
||||||
<line
|
<line
|
||||||
x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp}
|
x1={A.x} y1={A.y} x2={A.x + dxp} y2={A.y + dyp}
|
||||||
stroke={dirColor} strokeWidth={strokeW} strokeLinecap="round"
|
stroke={dirColor} strokeWidth={strokeW} strokeLinecap="round"
|
||||||
opacity={p.alive === false ? 0.5 : 1}
|
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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -3,21 +3,30 @@ import { headers } from 'next/headers'
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { MatchProvider } from './MatchContext'
|
import { MatchProvider } from './MatchContext'
|
||||||
import type { Match } from '@/app/types/match'
|
import type { Match } from '@/app/types/match'
|
||||||
|
import https from 'https'
|
||||||
|
import { Agent } from 'undici'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
export const revalidate = 0
|
export const revalidate = 0
|
||||||
// (optional) falls du sicher Node Runtime willst:
|
// (optional) falls du sicher Node Runtime willst:
|
||||||
// export const runtime = 'nodejs'
|
// export const runtime = 'nodejs'
|
||||||
|
|
||||||
|
|
||||||
async function loadMatch(matchId: string): Promise<Match | null> {
|
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 proto = (h.get('x-forwarded-proto') ?? 'http').split(',')[0].trim()
|
||||||
const host = (h.get('x-forwarded-host') ?? h.get('host') ?? '').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)
|
// ⚠️ Nur in Dev benutzen!
|
||||||
const base = host ? `${proto}://${host}` : (process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000')
|
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
|
if (!res.ok) return null
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user