diff --git a/.env b/.env index af4d42f..21ee999 100644 --- a/.env +++ b/.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 \ No newline at end of file +NEXT_PUBLIC_CS2_WS_HOST=ironieopen.local +NEXT_PUBLIC_CS2_WS_PORT=443 +NEXT_PUBLIC_CS2_WS_PATH=/telemetry \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 28b4051..75d6dd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 48ad5c9..0fc59fb 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/app/components/LiveRadar.tsx b/src/app/components/LiveRadar.tsx index 31f5716..1196a89 100644 --- a/src/app/components/LiveRadar.tsx +++ b/src/app/components/LiveRadar.tsx @@ -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 -} - -// ---- 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(null) - const [voteData, setVoteData] = useState(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(null) - const [maxImgHeight, setMaxImgHeight] = useState(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>(new Map()) - const [nadePaths, setNadePaths] = useState([]) - 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>(new Map()) - const [effects, setEffects] = useState([]) - 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(null) + + // Spieler (throttled) const playersRef = useRef>(new Map()) const [players, setPlayers] = useState([]) + + // Grenades (throttled) + const grenadesRef = useRef>(new Map()) + const [grenades, setGrenades] = useState([]) + + // gemeinsamer Flush (Players + Grenades) const flushTimer = useRef(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(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 port = process.env.NEXT_PUBLIC_CS2_WS_PORT || '' + const path = process.env.NEXT_PUBLIC_CS2_WS_PATH || '/telemetry' const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' - const path = process.env.NEXT_PUBLIC_CS2_WS_PATH || '/telemetry' - const url = explicit || `${proto}://${host}:${port}${path}` + 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) + 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, + }) + } - let cur = nadePathsRef.current.get(id) - if (!cur) cur = { id, kind, points: [], startedMs: now } + // >>> 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 last = cur.points[cur.points.length - 1] - const seg = Number.isFinite(s0) ? s0 : (last?.s ?? 0) + 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, + }) + } + } - const tooClose = last && Math.hypot(last.x - px, last.y - py) <= 1 - const sameTime = last && (last.t ?? 0) === t - if (tooClose && sameTime) return + // 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 [] - cur.points.push({ x: px, y: py, z: pz, t, s: seg }) - nadePathsRef.current.set(id, cur) + // 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 (Array.isArray(tr?.points)) tr.points.forEach(addPoint) - else if (tr?.pos || (tr?.x != null && tr?.y != null)) addPoint(tr.pos ?? tr) - - 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) } + // 2) Objekt mit Buckets (smokes, flashbangs, ...) + const buckets: Record = { + smoke: ['smokes', 'smoke', 'smokegrenade', 'smokegrenades'], + molotov: ['molotov', 'molotovs', 'inferno', 'fire', 'incendiary'], + he: ['he', 'hegrenade', 'hegrenades', 'explosive'], + flash: ['flash', 'flashbang', 'flashbangs'], + decoy: ['decoy', 'decoys'], } - 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 }) + 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), + }) + } } - 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() + 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() + 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(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(null) + const [maxImgHeight, setMaxImgHeight] = useState(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 (
@@ -642,19 +518,14 @@ export default function LiveRadar({ matchId }: Props) {
{activeMapKey - ? (voteData?.mapVisuals?.[activeMapKey]?.label ?? - activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase()) + ? activeMapKey.replace(/^de_/,'').replace(/_/g,' ').toUpperCase() : '—'}
- {loading ? ( -
- ) : error ? ( -
{error}
- ) : !activeMapKey ? ( + {!activeMapKey ? (
Keine Map erkannt.
@@ -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 */} - - - - - - - - - - - - - + {/* ───── 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 ( - - + + ) } + if (g.kind === 'molotov') { + return ( + + + + ) + } + if (g.kind === 'decoy') { + return ( + + + + ) + } + if (g.kind === 'flash') { + // kleiner Ring + Kreuz + return ( + + + + + + + ) + } + // HE + unknown: kompakter Punkt return ( - - + + ) })} - {/* 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) - 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 } - - const cur = Math.hypot(dxp, dyp) || 1 - dxp *= dirLenPx / cur - dyp *= dirLenPx / cur + // 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 + ) + dxp = B.x - A.x + dyp = B.y - A.y + const cur = Math.hypot(dxp, dyp) || 1 + dxp *= dirLenPx / cur + dyp *= dirLenPx / cur + } return ( - {/* Kreis zuerst */} - {/* Linie darüber (sichtbar) */} - - - ) - })} - - {/* Nade-Pfade */} - - {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 ( - - {d && ( - )} - {pts.map((p, i) => ( - - ))} ) })} - )} diff --git a/src/app/match-details/[matchId]/layout.tsx b/src/app/match-details/[matchId]/layout.tsx index 76b5075..61b8f5e 100644 --- a/src/app/match-details/[matchId]/layout.tsx +++ b/src/app/match-details/[matchId]/layout.tsx @@ -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 { - 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() }