This commit is contained in:
Linrador 2025-09-03 08:51:53 +02:00
commit f1773a0924
5 changed files with 359 additions and 499 deletions

10
.env
View File

@ -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
View File

@ -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",

View File

@ -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"
},

View File

@ -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)
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 ?? '')
}
// ---- WS
const [wsStatus, setWsStatus] =
useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
const RAD2DEG = 180 / Math.PI;
// ---- 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)))
}
update()
window.addEventListener('resize', update)
window.addEventListener('scroll', update, { passive: true })
return () => {
window.removeEventListener('resize', update)
window.removeEventListener('scroll', update)
}
}, [])
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>
)}
</>

View File

@ -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()
}