This commit is contained in:
Linrador 2025-09-08 22:53:28 +02:00
parent 9eaab19c5f
commit 5531a68da0
12 changed files with 586 additions and 170 deletions

12
.env
View File

@ -20,12 +20,16 @@ PTERO_SERVER_SFTP_USER=army.37a11489
PTERO_SERVER_SFTP_PASSWORD=IJHoYHTXQvJkCxkycTYM
PTERO_SERVER_ID=37a11489
# 🌍 Meta-WebSocket (CS2 Server Plugin)
NEXT_PUBLIC_CS2_META_WS_HOST=cs2.ironieopen.de
# META (vom CS2-Server-Plugin)
NEXT_PUBLIC_CS2_META_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_META_WS_PORT=443
NEXT_PUBLIC_CS2_META_WS_PATH=/telemetry
NEXT_PUBLIC_CS2_META_WS_SCHEME=wss
# 🖥️ Positionen / GSI-WebSocket (lokaler Aggregator)
# POS (lokaler Aggregator)
NEXT_PUBLIC_CS2_POS_WS_HOST=ironieopen.local
NEXT_PUBLIC_CS2_POS_WS_PORT=8082
NEXT_PUBLIC_CS2_POS_WS_PORT=443
NEXT_PUBLIC_CS2_POS_WS_PATH=/positions
NEXT_PUBLIC_CS2_POS_WS_SCHEME=wss
NEXT_PUBLIC_CONNECT_HREF="steam://connect/94.130.66.149:27015/0000"

View File

@ -63,7 +63,7 @@ function buildSteps(bestOf: number, teamAId: string, teamBId: string) {
{ order: 3, action: 'PICK', teamId: teamBId },
{ order: 4, action: 'PICK', teamId: teamAId },
{ order: 5, action: 'PICK', teamId: teamBId },
{ order: 6, action: 'PICK', teamId: teamAId },
{ order: 6, action: 'DECIDER', teamId: null },
] as const
}

View File

@ -1,10 +1,10 @@
// /app/api/matches/[matchId]/meta/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/app/lib/prisma'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth'
import { sendServerSSEMessage } from '@/app/lib/sse-server-client'
import { MAP_OPTIONS } from '@/app/lib/mapOptions'
import { MapVoteAction } from '@/generated/prisma'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@ -50,6 +50,46 @@ function voteOpensAt(base: Date, leadMinutes: number) {
return new Date(base.getTime() - leadMinutes * 60_000)
}
// Steps-Builder für BO1/BO3/BO5
function buildSteps(bestOf: 1 | 3 | 5, firstTeamId: string | null, secondTeamId: string | null) {
const A = firstTeamId
const B = secondTeamId
if (bestOf === 1) {
// Klassischer BO1: 6x Ban (A/B abwechselnd), danach Decider
return [
{ order: 0, action: 'BAN', teamId: A },
{ order: 1, action: 'BAN', teamId: B },
{ order: 2, action: 'BAN', teamId: A },
{ order: 3, action: 'BAN', teamId: B },
{ order: 4, action: 'BAN', teamId: A },
{ order: 5, action: 'BAN', teamId: B },
{ order: 6, action: 'DECIDER', teamId: null },
] as const
}
if (bestOf === 3) {
// 2x Ban, 2x Pick, 2x Ban, Decider
return [
{ order: 0, action: 'BAN', teamId: A },
{ order: 1, action: 'BAN', teamId: B },
{ order: 2, action: 'PICK', teamId: A },
{ order: 3, action: 'PICK', teamId: B },
{ order: 4, action: 'BAN', teamId: A },
{ order: 5, action: 'BAN', teamId: B },
{ order: 6, action: 'DECIDER', teamId: null },
] as const
}
// BO5: 2x Ban, dann 5 Picks (kein Decider)
return [
{ order: 0, action: 'BAN', teamId: A },
{ order: 1, action: 'BAN', teamId: B },
{ order: 2, action: 'PICK', teamId: A },
{ order: 3, action: 'PICK', teamId: B },
{ order: 4, action: 'PICK', teamId: A },
{ order: 5, action: 'PICK', teamId: B },
{ order: 6, action: 'PICK', teamId: A },
] as const
}
export async function PUT(
req: NextRequest,
{ params }: { params: { matchId: string } }
@ -71,15 +111,20 @@ export async function PUT(
map,
voteLeadMinutes, // optional
demoDate,
bestOf: bestOfRaw, // <- NEU
} = body ?? {}
// BestOf validieren (nur 1/3/5 zulassen)
const bestOf =
[1, 3, 5].includes(Number(bestOfRaw)) ? (Number(bestOfRaw) as 1 | 3 | 5) : undefined
try {
const match = await prisma.match.findUnique({
where: { id },
include: {
teamA: { include: { leader: true } },
teamB: { include: { leader: true } },
mapVote: true,
mapVote: { include: { steps: true } }, // <- Steps laden
},
})
if (!match) return NextResponse.json({ error: 'Match not found' }, { status: 404 })
@ -98,6 +143,7 @@ export async function PUT(
if (typeof map !== 'undefined') updateData.map = map
if (typeof teamAId !== 'undefined') updateData.teamAId = teamAId
if (typeof teamBId !== 'undefined') updateData.teamBId = teamBId
if (typeof bestOf !== 'undefined') updateData.bestOf = bestOf // <- BestOf updaten
const parsedMatchDate = parseDateOrNull(matchDate)
if (parsedMatchDate !== undefined) updateData.matchDate = parsedMatchDate
@ -106,7 +152,6 @@ export async function PUT(
if (parsedDemoDate !== undefined) {
updateData.demoDate = parsedDemoDate
} else if (parsedMatchDate instanceof Date) {
// demoDate mitschieben, wenn matchDate geändert und demoDate nicht gesendet wurde
updateData.demoDate = parsedMatchDate
}
@ -122,22 +167,29 @@ export async function PUT(
(match.matchDate ?? null) ??
(match.demoDate ?? null)
// 4) Updaten & opensAt ggf. neu setzen
// 4) Updaten & ggf. MapVote anlegen/aktualisieren + Reset bei BestOf-Änderung
const updated = await prisma.$transaction(async (tx) => {
const m = await tx.match.update({
where: { id },
data: updateData,
include: { mapVote: true },
include: { mapVote: { include: { steps: true } } },
})
// MapVote-Zeit/Lead pflegen (opensAt immer aus Basiszeit+Lead)
if (baseDate) {
const opensAt = voteOpensAt(baseDate, leadMinutes)
if (!m.mapVote) {
// Neu anlegen
const mapPool = MAP_OPTIONS.filter(o => o.active).map(o => o.key)
await tx.mapVote.create({
data: {
matchId: m.id,
leadMinutes,
bestOf : (m.bestOf as 1|3|5) ?? 3,
mapPool,
currentIdx: 0,
locked: false,
opensAt,
leadMinutes,
},
})
} else {
@ -150,26 +202,103 @@ export async function PUT(
})
}
} else if (leadBody !== undefined && m.mapVote) {
// Nur Lead geändert
await tx.mapVote.update({
where: { id: m.mapVote.id },
data: { leadMinutes },
})
}
// --- Reset, WENN bestOf übergeben wurde und sich etwas ändert ---
if (typeof bestOf !== 'undefined') {
const vote = await tx.mapVote.findUnique({
where: { matchId: m.id },
include: { steps: true },
})
// Wenn noch kein MapVote existiert, jetzt direkt mit Steps anlegen
if (!vote) {
const mapPool = MAP_OPTIONS.filter(o => o.active).map(o => o.key)
const opensAt = baseDate ? voteOpensAt(baseDate, leadMinutes) : null
const firstTeamId =
m.teamAId ?? null // Startheuristik: TeamA beginnt (kannst du auch randomisieren)
const secondTeamId = firstTeamId === m.teamAId ? m.teamBId ?? null : m.teamAId ?? null
const def = buildSteps(bestOf, firstTeamId, secondTeamId)
await tx.mapVote.create({
data: {
matchId : m.id,
bestOf : bestOf,
mapPool,
currentIdx: 0,
locked : false,
opensAt : opensAt ?? undefined,
leadMinutes,
steps : {
create: def.map(s => ({
order : s.order,
action: s.action as MapVoteAction,
teamId: s.teamId ?? undefined,
})),
},
},
})
} else {
// Prüfen: nur wenn tatsächlich abweicht → Reset
const differs = vote.bestOf !== bestOf
if (differs) {
const opensAt = baseDate ? voteOpensAt(baseDate, leadMinutes) : vote.opensAt ?? null
// "Erstes Team" für neuen Ablauf bestimmen:
const firstTeamId =
[...vote.steps].sort((a, b) => a.order - b.order)[0]?.teamId ??
m.teamAId ?? null
const secondTeamId = firstTeamId === m.teamAId ? m.teamBId ?? null : m.teamAId ?? null
// Alte Steps weg + neue Steps anlegen
await tx.mapVoteStep.deleteMany({ where: { voteId: vote.id } })
const def = buildSteps(bestOf, firstTeamId, secondTeamId)
await tx.mapVote.update({
where: { id: vote.id },
data: {
bestOf,
currentIdx: 0,
locked: false,
adminEditingBy: null,
adminEditingSince: null,
...(opensAt ? { opensAt } : {}),
steps: {
create: def.map(s => ({
order : s.order,
action: s.action as MapVoteAction,
teamId: s.teamId ?? undefined,
})),
},
},
})
// SSE: dediziertes Reset-Event
await sendServerSSEMessage({
type: 'map-vote-reset',
payload: { matchId: m.id },
})
}
}
}
return tx.match.findUnique({
where: { id },
where: { id: m.id },
include: {
teamA: { include: { leader: true } },
teamB: { include: { leader: true } },
mapVote: true,
mapVote: { include: { steps: true } },
},
})
})
if (!updated) return NextResponse.json({ error: 'Reload failed' }, { status: 500 })
// Immer map-vote-updated senden, wenn es einen MapVote gibt
// Immer map-vote-updated senden, wenn es einen MapVote gibt (Zeit/Lead)
if (updated.mapVote) {
await sendServerSSEMessage({
type: 'map-vote-updated',
@ -181,7 +310,6 @@ export async function PUT(
})
}
// 6) Response
return NextResponse.json({
id: updated.id,
title: updated.title,
@ -191,6 +319,7 @@ export async function PUT(
matchDate: updated.matchDate,
demoDate: updated.demoDate,
map: updated.map,
bestOf: updated.bestOf,
mapVote: updated.mapVote,
}, { headers: { 'Cache-Control': 'no-store' } })
} catch (err) {

View File

@ -145,26 +145,60 @@ export default function CommunityMatchList({ matchType }: Props) {
return () => { cancelled = true; clearTimeout(t) }
}, [lastEvent, loadMatches])
// Teams laden, wenn Modal aufgeht
// Teams laden, wenn Modal aufgeht (robust gegen verschiedene Response-Shapes)
useEffect(() => {
if (!showCreate || teams.length) return
if (!showCreate) return
let ignore = false
const ctrl = new AbortController()
;(async () => {
setLoadingTeams(true)
try {
const res = await fetch('/api/teams', { cache: 'no-store' })
const json = await res.json()
const opts: TeamOption[] = (json.teams ?? []).map((t: any) => ({
id: t.id, name: t.name, logo: t.logo,
const res = await fetch('/api/teams', {
cache: 'no-store',
credentials: 'same-origin', // wichtig: Cookies mitnehmen
signal: ctrl.signal,
})
const json = await res.json().catch(() => ({} as any))
// ➜ egal ob {teams: [...]}, {data: [...]}, {items: [...]} oder direkt [...]
const raw =
Array.isArray(json?.teams) ? json.teams :
Array.isArray(json?.data) ? json.data :
Array.isArray(json?.items) ? json.items :
Array.isArray(json) ? json :
[]
const opts: TeamOption[] = raw
.map((t: any) => ({
id: t.id ?? t._id ?? t.teamId ?? t.uuid ?? '',
name: t.name ?? t.title ?? t.displayName ?? t.tag ?? 'Unbenanntes Team',
logo: t.logo ?? t.logoUrl ?? t.image ?? null,
}))
setTeams(opts)
.filter((t: TeamOption) => !!t.id && !!t.name)
if (!ignore) setTeams(opts)
} catch (e) {
if (!ignore) {
console.error('[MatchList] /api/teams fehlgeschlagen:', e)
setTeams([])
}
} finally {
setLoadingTeams(false)
if (!ignore) setLoadingTeams(false)
}
})()
}, [showCreate, teams.length])
return () => { ignore = true; ctrl.abort() }
}, [showCreate])
useEffect(() => {
if (!showCreate) return
if (teams.length >= 2 && !teamAId && !teamBId) {
setTeamAId(teams[0].id)
setTeamBId(teams[1].id)
}
}, [teams, showCreate, teamAId, teamBId])
const resetCreateState = () => {
setTeamAId('')
@ -490,6 +524,12 @@ export default function CommunityMatchList({ matchType }: Props) {
{teamAId && teamBId && teamAId === teamBId && (
<p className="text-sm text-red-600 mt-2">Bitte zwei unterschiedliche Teams wählen.</p>
)}
{!loadingTeams && showCreate && teams.length === 0 && (
<p className="text-sm text-amber-600">
Keine Teams gefunden. Prüfe den /api/teams Response (erwartet id & name).
</p>
)}
</div>
</Modal>
</div>

View File

@ -1,11 +1,10 @@
// app/components/EditMatchMetaModal.tsx
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import Modal from '@/app/components/Modal'
import Alert from '@/app/components/Alert'
import Select from '@/app/components/Select'
import { MAP_OPTIONS } from '../lib/mapOptions'
type TeamOption = { id: string; name: string; logo?: string | null }
@ -19,9 +18,10 @@ type Props = {
defaultTeamAName?: string | null
defaultTeamBName?: string | null
defaultDateISO?: string | null
defaultMap?: string | null
defaultMap?: string | null // bleibt im Typ für Kompatibilität, wird aber nicht mehr genutzt
defaultVoteLeadMinutes?: number
onSaved?: () => void
defaultBestOf?: 1 | 3 | 5
}
export default function EditMatchMetaModal({
@ -34,40 +34,44 @@ export default function EditMatchMetaModal({
defaultTeamAName,
defaultTeamBName,
defaultDateISO,
defaultMap,
// defaultMap, // nicht mehr genutzt
defaultVoteLeadMinutes = 60,
onSaved,
defaultBestOf = 3,
}: Props) {
// -------- state
const [title, setTitle] = useState(defaultTitle ?? '')
const [teamAId, setTeamAId] = useState<string>(defaultTeamAId ?? '')
const [teamBId, setTeamBId] = useState<string>(defaultTeamBId ?? '')
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
const [date, setDate] = useState<string>(() => {
if (!defaultDateISO) return ''
const d = new Date(defaultDateISO)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
})
const [mapKey, setMapKey] = useState<string>(defaultMap ?? 'lobby_mapvote')
const [voteLead, setVoteLead] = useState<number>(defaultVoteLeadMinutes)
// Nur noch BestOf editierbar
const [bestOf, setBestOf] = useState<1 | 3 | 5>(defaultBestOf)
const [teams, setTeams] = useState<TeamOption[]>([])
const [loadingTeams, setLoadingTeams] = useState(false)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
// -------- load teams when open
const openedOnceRef = useRef(false)
// Teams laden
useEffect(() => {
if (!show) return
setLoadingTeams(true)
;(async () => {
try {
const res = await fetch('/api/teams', { cache: 'no-store' })
if (!res.ok) throw new Error(`Team-API: ${res.status}`)
const data = (await res.json()) as TeamOption[]
setTeams((Array.isArray(data) ? data : []).filter(t => t?.id && t?.name))
const data = res.ok ? await res.json() : []
const list: TeamOption[] = Array.isArray(data) ? data : (data.teams ?? [])
setTeams((list ?? []).filter((t: any) => t?.id && t?.name))
} catch (e) {
console.error('[EditMatchMetaModal] load teams failed:', e)
setTeams([])
@ -77,14 +81,17 @@ export default function EditMatchMetaModal({
})()
}, [show])
// -------- reset defaults on open
// Defaults beim Öffnen (einmal)
useEffect(() => {
if (!show) return
if (!show) { openedOnceRef.current = false; return }
if (openedOnceRef.current) return
openedOnceRef.current = true
setTitle(defaultTitle ?? '')
setTeamAId(defaultTeamAId ?? '')
setTeamBId(defaultTeamBId ?? '')
setMapKey(defaultMap ?? 'lobby_mapvote')
setVoteLead(defaultVoteLeadMinutes)
if (defaultDateISO) {
const d = new Date(defaultDateISO)
const pad = (n: number) => String(n).padStart(2, '0')
@ -92,6 +99,8 @@ export default function EditMatchMetaModal({
} else {
setDate('')
}
setBestOf(defaultBestOf ?? 3)
setSaved(false)
setError(null)
}, [
@ -100,31 +109,25 @@ export default function EditMatchMetaModal({
defaultTeamAId,
defaultTeamBId,
defaultDateISO,
defaultMap,
defaultVoteLeadMinutes,
defaultBestOf,
])
// -------- derived: options
const teamOptionsA = useMemo(() => {
// Team B nicht in A auswählbar machen
return teams
.filter(t => t.id !== teamBId)
.map(t => ({ value: t.id, label: t.name }));
}, [teams, teamBId]);
const teamOptionsB = useMemo(() => {
// Team A nicht in B auswählbar machen
return teams
.filter(t => t.id !== teamAId)
.map(t => ({ value: t.id, label: t.name }));
}, [teams, teamAId]);
const mapOptions = useMemo(
() => MAP_OPTIONS.map(m => ({ value: m.key, label: m.label })),
[]
// Optionen
const teamOptionsA = useMemo(
() => teams.filter(t => t.id !== teamBId).map(t => ({ value: t.id, label: t.name })),
[teams, teamBId]
)
const teamOptionsB = useMemo(
() => teams.filter(t => t.id !== teamAId).map(t => ({ value: t.id, label: t.name })),
[teams, teamAId]
)
// -------- validation
// Hinweis-Flag: Best Of geändert?
const defaultBestOfNormalized = (defaultBestOf ?? 3) as 1 | 3 | 5
const bestOfChanged = bestOf !== defaultBestOfNormalized
// Validation
const canSave = useMemo(() => {
if (saving) return false
if (!date) return false
@ -132,7 +135,7 @@ export default function EditMatchMetaModal({
return true
}, [saving, date, teamAId, teamBId])
// -------- save
// Save → nur bestOf wird (zusätzlich) übertragen; Server resettet MapVote bei Änderung
const handleSave = async () => {
setSaving(true)
setError(null)
@ -142,8 +145,8 @@ export default function EditMatchMetaModal({
teamAId: teamAId || null,
teamBId: teamBId || null,
matchDate: date ? new Date(date).toISOString() : null,
map: mapKey || null,
voteLeadMinutes: Number.isFinite(Number(voteLead)) ? Number(voteLead) : 60,
bestOf, // <- wichtig
}
const res = await fetch(`/api/matches/${matchId}/meta`, {
@ -158,9 +161,7 @@ export default function EditMatchMetaModal({
setSaved(true)
onClose()
setTimeout(() => {
onSaved?.()
}, 0)
setTimeout(() => onSaved?.(), 0)
} catch (e: any) {
console.error('[EditMatchMetaModal] save error:', e)
setError(e?.message || 'Speichern fehlgeschlagen')
@ -169,7 +170,6 @@ export default function EditMatchMetaModal({
}
}
// Platzhalter mit aktuellem Namen (falls Options noch laden)
const teamAPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamAName || 'Team A wählen …')
const teamBPlaceholder = loadingTeams ? 'Teams laden …' : (defaultTeamBName || 'Team B wählen …')
@ -241,18 +241,6 @@ export default function EditMatchMetaModal({
</p>
</div>
{/* Map */}
<div className="col-span-2 sm:col-span-1">
<label className="block text-sm font-medium mb-1">Map</label>
<Select
options={mapOptions}
value={mapKey}
onChange={setMapKey}
placeholder="Map wählen …"
dropDirection="auto"
/>
</div>
{/* Vote-Lead */}
<div>
<label className="block text-sm font-medium mb-1">Map-Vote lead (Minuten)</label>
@ -267,6 +255,33 @@ export default function EditMatchMetaModal({
Zeit vor Matchstart, zu der das Vote öffnet (Standard 60).
</p>
</div>
{/* Nur noch Best Of */}
<div className="col-span-2">
<label className="block text-sm font-medium mb-1">Modus (Best of)</label>
<div className="flex gap-2">
{[1, 3, 5].map(bo => (
<button
key={bo}
type="button"
onClick={() => setBestOf(bo as 1|3|5)}
className={`px-3 py-1.5 rounded-lg text-sm border
${bestOf === bo
? 'bg-blue-600 text-white border-blue-600'
: 'bg-transparent border-gray-300 dark:border-neutral-700 text-gray-800 dark:text-neutral-200'}`}
>
BO{bo}
</button>
))}
</div>
{bestOfChanged && (
<Alert type="soft" color="warning" className="mt-2">
Du hast den Modus von <b>BO{defaultBestOfNormalized}</b> auf <b>BO{bestOf}</b> geändert.
Beim Speichern wird der Map-Vote zurückgesetzt (alle bisherigen Schritte/Maps werden verworfen).
</Alert>
)}
</div>
</div>
</Modal>
)

View File

@ -560,6 +560,9 @@ export function MatchDetails({ match, initialNow }: { match: Match; initialNow:
defaultDateISO={match.matchDate ?? match.demoDate ?? null}
defaultMap={match.map ?? null}
defaultVoteLeadMinutes={match.mapVote?.leadMinutes ?? 60}
// ⬇️ neu:
defaultBestOf={(match.bestOf as 1 | 3 | 5) ?? 3}
defaultSeries={extractSeriesMaps(match)} // Array mit map-Keys (kann '' enthalten)
onSaved={() => { setTimeout(() => router.refresh(), 0) }}
/>
)}

View File

@ -39,12 +39,17 @@ export default function MatchReadyOverlay({
deadlineAt,
onTimeout,
forceGif,
connectHref = 'steam://connect/cs2.ironieopen.de:27015/ironie',
connectHref
}: Props) {
const { data: session } = useSession()
const mySteamId = session?.user?.steamId
const { lastEvent } = useSSEStore()
const ENV_CONNECT_HREF = process.env.NEXT_PUBLIC_CONNECT_HREF
const DEFAULT_CONNECT_HREF = 'steam://connect/94.130.66.149:27015/0000'
const effectiveConnectHref = connectHref ?? ENV_CONNECT_HREF ?? DEFAULT_CONNECT_HREF
const [now, setNow] = useState(() => Date.now())
const [startedAt] = useState(() => Date.now())
const fallbackDeadline = useMemo(() => startedAt + 20_000, [startedAt])
@ -54,7 +59,7 @@ export default function MatchReadyOverlay({
// UI-States
const [accepted, setAccepted] = useState(false)
const [finished, setFinished] = useState(false)
const [showWaitHint, setShowWaitHint] = useState(false)
const [showWaitHint, setShowWaitHint] = useState(false) // ⬅️ nutzt du unten zum Ausblenden des Countdowns
const [connecting, setConnecting] = useState(false)
const isVisible = open || accepted || showWaitHint
@ -144,7 +149,7 @@ export default function MatchReadyOverlay({
useEffect(() => {
if (!isVisible) { setShowBackdrop(false); setShowContent(false); return }
setShowBackdrop(true)
const id = setTimeout(() => setShowContent(true), 2000)
const id = setTimeout(() => setShowContent(true), 300) // vorher: 2000
return () => clearTimeout(id)
}, [isVisible])
@ -158,6 +163,7 @@ export default function MatchReadyOverlay({
}, [accepted, loadReady])
// SSE
const { lastEvent: le } = useSSEStore()
useEffect(() => {
if (!lastEvent) return
const type = (lastEvent as any).type ?? (lastEvent as any)?.payload?.type
@ -230,9 +236,15 @@ export default function MatchReadyOverlay({
}, [isVisible])
// ----- AUDIO: Beeps starten/stoppen -----
// Beeps erst starten, wenn der Content sichtbar ist
useEffect(() => {
if (!isVisible) { stopBeeps(); audioStartedRef.current = false; return }
if (!showContent) { // vorher: if (!isVisible)
stopBeeps()
audioStartedRef.current = false
return
}
if (audioStartedRef.current) return
let cleanup = () => {}
;(async () => {
const ok = await ensureAudioUnlocked()
@ -250,8 +262,9 @@ export default function MatchReadyOverlay({
window.removeEventListener('keydown', onGesture)
}
})()
return () => { cleanup(); stopBeeps() }
}, [isVisible])
}, [showContent]) // vorher: [isVisible]
// ----- countdown / timeout -----
const rafRef = useRef<number | null>(null)
@ -269,11 +282,11 @@ export default function MatchReadyOverlay({
try { sound.play('loading') } catch {}
const doConnect = () => {
try { window.location.href = connectHref }
try { window.location.href = effectiveConnectHref }
catch {
try {
const a = document.createElement('a')
a.href = connectHref
a.href = effectiveConnectHref
document.body.appendChild(a)
a.click()
a.remove()
@ -283,7 +296,7 @@ export default function MatchReadyOverlay({
}
setTimeout(doConnect, 2000)
} else {
setShowWaitHint(true)
setShowWaitHint(true) // ⬅️ triggert Hinweis „Dein Team wartet auf dich!“
}
return
}
@ -293,16 +306,6 @@ export default function MatchReadyOverlay({
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
}, [isVisible, effectiveDeadline, accepted, finished, connectHref, onTimeout])
// ---- Präsenz → Rahmenfarbe ----
const borderByPresence = (s: Presence | undefined): string => {
switch (s) {
case 'online': return 'border-[#2ecc71]'
case 'away': return 'border-yellow-400'
case 'offline':
default: return 'border-white/20'
}
}
// 🔎 Map-Icon aus MAP_OPTIONS ermitteln
const mapIconUrl = useMemo(() => {
const norm = (s?: string | null) => (s ?? '').trim().toLowerCase()
@ -325,7 +328,10 @@ export default function MatchReadyOverlay({
const p = participants[i]
const isReady = p ? !!readyMap[p.steamId] : false
const presence: Presence = (p && statusMap[p.steamId]) || 'offline'
const borderCls = borderByPresence(presence)
const borderCls =
presence === 'online' ? 'border-[#2ecc71]' :
presence === 'away' ? 'border-yellow-400' :
'border-white/20'
return (
<div
@ -372,7 +378,7 @@ export default function MatchReadyOverlay({
{/* Backdrop: 2s-Fade */}
<div
className={[
'absolute inset-0 bg-black/60 transition-opacity duration-[2000ms] ease-out',
'absolute inset-0 bg-black/30 transition-opacity duration-[2000ms] ease-out',
showBackdrop ? 'opacity-100' : 'opacity-0'
].join(' ')}
/>
@ -389,24 +395,41 @@ export default function MatchReadyOverlay({
].join(' ')}
>
{/* Map */}
<img src={mapBg} alt={mapLabel} className="absolute inset-0 w-full h-full object-cover brightness-90" />
<img
src={mapBg}
alt={mapLabel}
className="absolute inset-0 w-full h-full object-cover brightness-90"
/>
{/* Deko-Layer */}
{/* Deko-Layer (Gif/Video) */}
{useGif ? (
<div className="absolute inset-0 opacity-50 pointer-events-none">
<img src="/assets/vids/overlay_cs2_accept.gif" alt="" className="absolute inset-0 w-full h-full object-cover" decoding="async" loading="eager" />
<img
src="/assets/vids/overlay_cs2_accept.webp"
alt=""
className="absolute inset-0 w-full h-full object-cover"
decoding="async"
loading="eager"
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_20%,rgba(255,255,255,0.08),transparent_60%)]" />
</div>
) : (
<video
ref={videoRef}
className="absolute inset-0 w-full h-full object-cover opacity-50 pointer-events-none"
autoPlay loop muted playsInline preload="auto"
autoPlay
loop
muted
playsInline
preload="auto"
>
<source src="/assets/vids/overlay_cs2_accept.webm" type="video/webm" />
</video>
)}
{/* 🔽 NEU: dunkler Gradient wie bei „Gewählte Maps“ */}
<div className="absolute inset-0 z-[5] pointer-events-none bg-gradient-to-b from-black/80 via-black/65 to-black/80" />
{/* Inhalt */}
<div className="relative z-10 h-full w-full flex flex-col items-center">
<div className="mt-[28px] text-[30px] font-semibold text-[#6ae364]">
@ -414,7 +437,7 @@ export default function MatchReadyOverlay({
<span aria-hidden className="block h-px w-full bg-[#6ae364] shadow-[1px_1px_1px_#6ae3642c] rounded-sm" />
</div>
{/* ⬇️ Icon aus MAP_OPTIONS + Label */}
{/* Icon + Label */}
<div className="mt-[10px] flex items-center justify-center text-[#8af784]">
<img src={mapIconUrl} alt={`${mapLabel} Icon`} className="w-5 h-5 object-contain" />
<span className="ml-2 text-[15px] [transform:scale(1,0.9)]">{mapLabel}</span>
@ -431,7 +454,7 @@ export default function MatchReadyOverlay({
Dein Team wartet auf dich!
</div>
<a
href={connectHref}
href={effectiveConnectHref}
className="px-4 py-2 rounded-md bg-[#61d365] hover:bg-[#4dc250] text-[#174d10] font-semibold text-lg shadow"
>
Verbinden
@ -449,6 +472,8 @@ export default function MatchReadyOverlay({
)}
{/* Countdown oder Verbinde-Status */}
{/* 🔽 NEU: Countdown ausblenden, wenn der Warte-Hinweis gezeigt wird */}
{!showWaitHint && (
<div className="mt-[6px] text-[#63d45d] font-bold text-[20px]">
{connecting ? (
<span
@ -463,6 +488,7 @@ export default function MatchReadyOverlay({
<span>{fmt(rest)}</span>
)}
</div>
)}
</div>
</div>
)}

View File

@ -1,5 +1,3 @@
// /app/components/radar/LiveRadar.tsx
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
@ -14,7 +12,8 @@ const UI = {
dirLenRel: 0.70,
dirMinLenPx: 6,
lineWidthRel: 0.25,
stroke: '#ffffff',
stroke: '#ffffff', // normaler Outline (weiß)
bombStroke: '#ef4444', // Outline wenn Bombe (rot)
fillCT: '#3b82f6',
fillT: '#f59e0b',
dirColor: 'auto' as 'auto' | string,
@ -30,6 +29,17 @@ const UI = {
teamStrokeT: '#f59e0b',
minRadiusPx: 6,
},
death: {
stroke: '#9ca3af', // graues X
lineWidthPx: 2,
sizePx: 10,
},
trail: {
maxPoints: 60,
fadeMs: 1500,
stroke: 'rgba(60,60,60,0.7)',
widthPx: 2,
}
}
/* ───────── helpers ───────── */
@ -49,20 +59,72 @@ function mapTeam(t: any): 'T' | 'CT' | string {
return String(t ?? '')
}
function buildWsUrl(prefix: 'CS2_META' | 'CS2_POS') {
const host = process.env[`NEXT_PUBLIC_${prefix}_WS_HOST`] || '127.0.0.1'
const port = String(process.env[`NEXT_PUBLIC_${prefix}_WS_PORT`] || (prefix === 'CS2_META' ? '443' : '8082'))
const path = process.env[`NEXT_PUBLIC_${prefix}_WS_PATH`] || '/telemetry'
// Heuristik: wenn explizit 443 -> wss, wenn Seite https und Host != localhost -> wss, sonst ws
const isLocal = ['127.0.0.1', 'localhost', '::1'].includes(host)
const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
const proto = (port === '443' || (!isLocal && pageHttps)) ? 'wss' : 'ws'
const portPart = (port === '80' || port === '443') ? '' : `:${port}`
return `${proto}://${host}${portPart}${path}`
// Versuche robust zu erkennen, ob ein Spieler die Bombe hat
function detectHasBomb(src: any): boolean {
const flags = [
'hasBomb','has_bomb','bomb','c4','hasC4','carryingBomb','bombCarrier','isBombCarrier'
]
for (const k of flags) {
if (typeof src?.[k] === 'boolean') return !!src[k]
if (typeof src?.[k] === 'string') {
const s = String(src[k]).toLowerCase()
if (s === 'true' || s === '1' || s === 'c4' || s.includes('bomb')) return true
}
}
const arrays = [src?.weapons, src?.inventory, src?.items]
for (const arr of arrays) {
if (!arr) continue
if (Array.isArray(arr)) {
if (arr.some((w:any)=>
typeof w === 'string'
? w.toLowerCase().includes('c4') || w.toLowerCase().includes('bomb')
: (w?.name||w?.type||w?.weapon||'').toLowerCase().includes('c4') ||
(w?.name||w?.type||w?.weapon||'').toLowerCase().includes('bomb')
)) return true
} else if (typeof arr === 'object') {
const vals = Object.values(arr)
if (vals.some((w:any)=>
typeof w === 'string'
? w.toLowerCase().includes('c4') || w.toLowerCase().includes('bomb')
: (w?.name||w?.type||w?.weapon||'').toLowerCase().includes('c4') ||
(w?.name||w?.type||w?.weapon||'').toLowerCase().includes('bomb')
)) return true
}
}
return false
}
// URL-Builder
function makeWsUrl(
host?: string,
port?: string,
path?: string,
scheme?: string
) {
const h = (host ?? '').trim() || '127.0.0.1'
const p = (port ?? '').trim() || '8081'
const pa = (path ?? '').trim() || '/telemetry'
const sch = (scheme ?? '').toLowerCase()
const pageHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'
const useWss = sch === 'wss' || (sch !== 'ws' && (p === '443' || pageHttps))
const proto = useWss ? 'wss' : 'ws'
const portPart = (p === '80' || p === '443') ? '' : `:${p}`
return `${proto}://${h}${portPart}${pa}`
}
const metaUrl = makeWsUrl(
process.env.NEXT_PUBLIC_CS2_META_WS_HOST,
process.env.NEXT_PUBLIC_CS2_META_WS_PORT,
process.env.NEXT_PUBLIC_CS2_META_WS_PATH,
process.env.NEXT_PUBLIC_CS2_META_WS_SCHEME
)
const posUrl = makeWsUrl(
process.env.NEXT_PUBLIC_CS2_POS_WS_HOST,
process.env.NEXT_PUBLIC_CS2_POS_WS_PORT,
process.env.NEXT_PUBLIC_CS2_POS_WS_PATH,
process.env.NEXT_PUBLIC_CS2_POS_WS_SCHEME
)
const RAD2DEG = 180 / Math.PI
const normalizeDeg = (d: number) => (d % 360 + 360) % 360
@ -83,6 +145,7 @@ type PlayerState = {
z: number
yaw?: number | null
alive?: boolean
hasBomb?: boolean
}
type Grenade = {
id: string
@ -94,22 +157,36 @@ type Grenade = {
expiresAt?: number | null
team?: 'T' | 'CT' | string | null
}
type DeathMarker = { id: string; x: number; y: number; t: number }
type Trail = { id: string; kind: Grenade['kind']; pts: {x:number,y:number}[]; lastSeen: number }
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() {
// WS-Status separat anzeigen
// WS-Status
const [metaWsStatus, setMetaWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
const [posWsStatus, setPosWsStatus] = useState<'idle'|'connecting'|'open'|'closed'|'error'>('idle')
// Zustand
// Map
const [activeMapKey, setActiveMapKey] = useState<string | null>(null)
// Spieler
const playersRef = useRef<Map<string, PlayerState>>(new Map())
const [players, setPlayers] = useState<PlayerState[]>([])
// Grenaden + Trails
const grenadesRef = useRef<Map<string, Grenade>>(new Map())
const [grenades, setGrenades] = useState<Grenade[]>([])
const trailsRef = useRef<Map<string, Trail>>(new Map())
const [trails, setTrails] = useState<Trail[]>([])
// Death-Marker
const deathMarkersRef = useRef<DeathMarker[]>([])
const [deathMarkers, setDeathMarkers] = useState<DeathMarker[]>([])
// Flush
const flushTimer = useRef<number | null>(null)
const scheduleFlush = () => {
if (flushTimer.current != null) return
@ -117,9 +194,10 @@ export default function LiveRadar() {
flushTimer.current = null
setPlayers(Array.from(playersRef.current.values()))
setGrenades(Array.from(grenadesRef.current.values()))
setTrails(Array.from(trailsRef.current.values()))
setDeathMarkers([...deathMarkersRef.current])
}, 66)
}
useEffect(() => {
return () => {
if (flushTimer.current != null) {
@ -129,11 +207,21 @@ export default function LiveRadar() {
}
}, [])
const metaUrl = buildWsUrl('CS2_META')
const posUrl = buildWsUrl('CS2_POS')
// Runden-/Map-Reset
const clearRoundArtifacts = () => {
deathMarkersRef.current = []
trailsRef.current.clear()
grenadesRef.current.clear()
scheduleFlush()
}
useEffect(() => {
if (activeMapKey) clearRoundArtifacts()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeMapKey])
/* ───────── Meta-Callbacks ───────── */
const handleMetaMap = (key: string) => setActiveMapKey(key.toLowerCase())
const handleMetaPlayersSnapshot = (list: Array<{ steamId: string|number; name?: string; team?: any }>) => {
for (const p of list) {
const id = String(p.steamId ?? '')
@ -146,10 +234,12 @@ export default function LiveRadar() {
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null,
alive: old?.alive,
hasBomb: old?.hasBomb ?? false,
})
}
scheduleFlush()
}
const handleMetaPlayerJoin = (p: any) => {
const id = String(p?.steamId ?? p?.id ?? p?.name ?? '')
if (!id) return
@ -161,9 +251,11 @@ export default function LiveRadar() {
x: old?.x ?? 0, y: old?.y ?? 0, z: old?.z ?? 0,
yaw: old?.yaw ?? null,
alive: true,
hasBomb: old?.hasBomb ?? false,
})
scheduleFlush()
}
const handleMetaPlayerLeave = (steamId: string | number) => {
const id = String(steamId)
const old = playersRef.current.get(id)
@ -174,6 +266,10 @@ export default function LiveRadar() {
}
/* ───────── Positions-Callbacks ───────── */
const addDeathMarker = (x:number, y:number, idHint?: string) => {
deathMarkersRef.current.push({ id: idHint ?? `d#${Date.now()}`, x, y, t: Date.now() })
}
const upsertPlayer = (e: any) => {
const id = String(e.steamId ?? e.steam_id ?? e.userId ?? e.playerId ?? e.id ?? e.name ?? '')
if (!id) return
@ -194,18 +290,28 @@ export default function LiveRadar() {
)
const old = playersRef.current.get(id)
const nextAlive = (e.alive !== undefined) ? !!e.alive : old?.alive
const hasBomb = detectHasBomb(e) || old?.hasBomb
// Alive→Dead → Death-X an aktueller Position speichern
if (old?.alive !== false && nextAlive === false) addDeathMarker(x, y, id)
playersRef.current.set(id, {
id,
name: e.name ?? old?.name ?? null,
team: mapTeam(e.team ?? old?.team),
x, y, z,
yaw: Number.isFinite(yaw) ? yaw : old?.yaw ?? null,
alive: e.alive ?? old?.alive,
alive: nextAlive,
hasBomb: !!hasBomb,
})
}
const handlePlayersAll = (msg: any) => {
const ap = msg?.allplayers
if (!ap || typeof ap !== 'object') return
let total = 0, aliveCount = 0
for (const key of Object.keys(ap)) {
const p = ap[key]
const pos = parseVec3String(p.position)
@ -213,16 +319,33 @@ export default function LiveRadar() {
const yaw = normalizeDeg(Math.atan2(fwd.y, fwd.x) * RAD2DEG)
const id = String(key)
const old = playersRef.current.get(id)
const isAlive = p.state?.health > 0 || p.state?.health == null
const hasBomb = detectHasBomb(p) || old?.hasBomb
if ((old?.alive ?? true) && !isAlive) addDeathMarker(pos.x, pos.y, id)
playersRef.current.set(id, {
id,
name: p.name ?? old?.name ?? null,
team: mapTeam(p.team ?? old?.team),
x: pos.x, y: pos.y, z: pos.z,
yaw,
alive: p.state?.health > 0 || p.state?.health == null ? true : false,
alive: isAlive,
hasBomb: !!hasBomb,
})
total++
if (isAlive) aliveCount++
}
// Heuristik: Neue Runde → alles leeren
if (total > 0 && aliveCount === total && (deathMarkersRef.current.length > 0 || trailsRef.current.size > 0 || grenadesRef.current.size > 0)) {
clearRoundArtifacts()
}
scheduleFlush()
}
const normalizeGrenades = (raw: any): Grenade[] => {
if (!raw) return []
const pickTeam = (t: any): 'T' | 'CT' | string | null => {
@ -291,14 +414,41 @@ export default function LiveRadar() {
}
return out
}
const handleGrenades = (g: any) => {
const list = normalizeGrenades(g)
// Trails updaten
const seen = new Set<string>()
const now = Date.now()
for (const it of list) {
seen.add(it.id)
const prev = trailsRef.current.get(it.id) ?? { id: it.id, kind: it.kind, pts: [], lastSeen: 0 }
const last = prev.pts[prev.pts.length - 1]
if (!last || Math.hypot(it.x - last.x, it.y - last.y) > 1) {
prev.pts.push({ x: it.x, y: it.y })
if (prev.pts.length > UI.trail.maxPoints) prev.pts = prev.pts.slice(-UI.trail.maxPoints)
}
prev.kind = it.kind
prev.lastSeen = now
trailsRef.current.set(it.id, prev)
}
// Nicht mehr gesehene Trails ausdünnen
for (const [id, tr] of trailsRef.current) {
if (!seen.has(id) && now - tr.lastSeen > UI.trail.fadeMs) {
trailsRef.current.delete(id)
}
}
// aktuelle Nades übernehmen
const next = new Map<string, Grenade>()
for (const it of list) next.set(it.id, it)
grenadesRef.current = next
scheduleFlush()
}
// gemeinsamer flush bei Positionsdaten
// erster Flush
useEffect(() => {
if (!playersRef.current && !grenadesRef.current) return
scheduleFlush()
@ -515,7 +665,7 @@ export default function LiveRadar() {
<div className="w-full">
<div
className="relative mx-auto rounded-lg overflow-hidden border border-neutral-300 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 inline-block"
style={{ maxHeight: maxImgHeight ?? undefined }}
style={{ maxHeight: (typeof window !== 'undefined' ? (window.innerHeight - (headerRef.current?.getBoundingClientRect().bottom ?? 0) - 16) : undefined) ?? undefined }}
>
{currentSrc ? (
<>
@ -524,7 +674,6 @@ export default function LiveRadar() {
src={currentSrc}
alt={activeMapKey}
className="block h-auto max-w-full"
style={{ maxHeight: maxImgHeight ?? undefined }}
onLoad={(e) => {
const img = e.currentTarget
setImgSize({ w: img.naturalWidth, h: img.naturalHeight })
@ -540,6 +689,26 @@ export default function LiveRadar() {
viewBox={`0 0 ${imgSize.w} ${imgSize.h}`}
preserveAspectRatio="xMidYMid meet"
>
{/* Trails */}
{trails.map(tr => {
const pts = tr.pts.map(p => {
const q = worldToPx(p.x, p.y)
return `${q.x},${q.y}`
}).join(' ')
if (!pts) return null
return (
<polyline
key={`trail-${tr.id}`}
points={pts}
fill="none"
stroke={UI.trail.stroke}
strokeWidth={UI.trail.widthPx}
strokeLinecap="round"
strokeLinejoin="round"
/>
)
})}
{/* Grenades */}
{grenades.map((g) => {
const P = worldToPx(g.x, g.y)
@ -578,15 +747,15 @@ export default function LiveRadar() {
return <circle key={g.id} cx={P.x} cy={P.y} r={Math.max(4, rPx*0.4)} fill={g.kind === 'he' ? UI.nade.heFill : '#999'} stroke={stroke} strokeWidth={Math.max(1, sw*0.8)} />
})}
{/* Spieler */}
{/* Spieler (nur lebende anzeigen; Tote werden als X separat gezeichnet) */}
{players
.filter(p => p.team === 'CT' || p.team === 'T')
.filter(p => (p.team === 'CT' || p.team === 'T') && p.alive !== false)
.map((p) => {
const A = worldToPx(p.x, p.y)
const base = Math.min(imgSize.w, imgSize.h)
const r = Math.max(UI.player.minRadiusPx, base * UI.player.radiusRel)
const dirLenPx = Math.max(UI.player.dirMinLenPx, r * UI.player.dirLenRel)
const stroke = UI.player.stroke
const stroke = p.hasBomb ? UI.player.bombStroke : UI.player.stroke
const strokeW = Math.max(1, r * UI.player.lineWidthRel)
const fillColor = p.team === 'CT' ? UI.player.fillCT : UI.player.fillT
const dirColor = UI.player.dirColor === 'auto' ? contrastStroke(fillColor) : UI.player.dirColor
@ -612,18 +781,30 @@ export default function LiveRadar() {
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}
/>
{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>
)
})}
{/* Death-Marker (graues X an Todesposition) */}
{deathMarkers.map(dm => {
const P = worldToPx(dm.x, dm.y)
const s = UI.death.sizePx
return (
<g key={`death-${dm.t}-${dm.x}-${dm.y}`}>
<line x1={P.x - s} y1={P.y - s} x2={P.x + s} y2={P.y + s}
stroke={UI.death.stroke} strokeWidth={UI.death.lineWidthPx} strokeLinecap="round" />
<line x1={P.x - s} y1={P.y + s} x2={P.x + s} y2={P.y - s}
stroke={UI.death.stroke} strokeWidth={UI.death.lineWidthPx} strokeLinecap="round" />
</g>
)
})}
</svg>
)}
</>

View File

@ -1,10 +1,7 @@
// /app/components/MetaSocket.tsx
'use client'
import { useEffect, useRef } from 'react'
type Status = 'idle'|'connecting'|'open'|'closed'|'error'
type MetaSocketProps = {
url?: string
onStatus?: (s: Status) => void
@ -26,45 +23,59 @@ export default function MetaSocket({
const aliveRef = useRef(true)
const retryRef = useRef<number | null>(null)
// aktuelle Handler in Refs spiegeln (ändern NICHT die Effect-Dependencies)
const onMapRef = useRef(onMap)
const onPlayersSnapshotRef = useRef(onPlayersSnapshot)
const onPlayerJoinRef = useRef(onPlayerJoin)
const onPlayerLeaveRef = useRef(onPlayerLeave)
useEffect(() => { onMapRef.current = onMap }, [onMap])
useEffect(() => { onPlayersSnapshotRef.current = onPlayersSnapshot }, [onPlayersSnapshot])
useEffect(() => { onPlayerJoinRef.current = onPlayerJoin }, [onPlayerJoin])
useEffect(() => { onPlayerLeaveRef.current = onPlayerLeave }, [onPlayerLeave])
useEffect(() => {
aliveRef.current = true
const connect = () => {
if (!aliveRef.current) return
if (!aliveRef.current || !url) return
onStatus?.('connecting')
const ws = new WebSocket(url!)
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => onStatus?.('open')
ws.onerror = () => onStatus?.('error')
ws.onclose = () => {
onStatus?.('closed')
// optional: Backoff oder ganz ohne Auto-Reconnect, je nach Wunsch
if (aliveRef.current) retryRef.current = window.setTimeout(connect, 2000)
}
ws.onmessage = (ev) => {
let msg: any = null
try { msg = JSON.parse(String(ev.data ?? '')) } catch {}
if (!msg) return
// KEINE matchId-Filterung mehr
if (msg.type === 'map' && typeof msg.name === 'string') {
onMap?.(msg.name.toLowerCase())
onMapRef.current?.(msg.name.toLowerCase())
} else if (msg.type === 'players' && Array.isArray(msg.players)) {
onPlayersSnapshot?.(msg.players)
onPlayersSnapshotRef.current?.(msg.players)
} else if (msg.type === 'player_join' && msg.player) {
onPlayerJoin?.(msg.player)
onPlayerJoinRef.current?.(msg.player)
} else if (msg.type === 'player_leave') {
onPlayerLeave?.(msg.steamId ?? msg.steam_id ?? msg.id)
onPlayerLeaveRef.current?.(msg.steamId ?? msg.steam_id ?? msg.id)
}
}
}
if (url) connect()
connect()
return () => {
aliveRef.current = false
if (retryRef.current) window.clearTimeout(retryRef.current)
try { wsRef.current?.close(1000, 'meta unmounted') } catch {}
}
}, [url, onStatus, onMap, onPlayersSnapshot, onPlayerJoin, onPlayerLeave])
}, [url, onStatus]) // <— nur auf url/onStatus hören!
return null
}

View File

@ -1,4 +1,3 @@
// /app/components/PositionsSocket.tsx
'use client'
import { useEffect, useRef } from 'react'
@ -12,6 +11,8 @@ type PositionsSocketProps = {
onPlayerUpdate?: (p: any) => void
onPlayersAll?: (allplayers: any) => void
onGrenades?: (g: any) => void
onRoundStart?: () => void // ⬅️ NEU
onRoundEnd?: () => void // ⬅️ optional
}
export default function PositionsSocket({
@ -21,6 +22,8 @@ export default function PositionsSocket({
onPlayerUpdate,
onPlayersAll,
onGrenades,
onRoundStart,
onRoundEnd,
}: PositionsSocketProps) {
const wsRef = useRef<WebSocket | null>(null)
const aliveRef = useRef(true)
@ -29,7 +32,11 @@ export default function PositionsSocket({
const dispatch = (msg: any) => {
if (!msg) return
// KEINE matchId-Filterung mehr
// Runde:
if (msg.type === 'round_start') { onRoundStart?.(); return }
if (msg.type === 'round_end') { onRoundEnd?.(); return }
// Tick (Fast-Path)
if (msg.type === 'tick') {
if (typeof msg.map === 'string') onMap?.(msg.map.toLowerCase())
if (Array.isArray(msg.players)) msg.players.forEach(onPlayerUpdate ?? (() => {}))

View File

@ -354,7 +354,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -368,7 +368,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {

View File

@ -355,7 +355,7 @@ const config = {
"value": "prisma-client-js"
},
"output": {
"value": "C:\\Users\\Rother\\fork\\ironie-nextjs\\src\\generated\\prisma",
"value": "C:\\Users\\Chris\\fork\\ironie-nextjs\\src\\generated\\prisma",
"fromEnvVar": null
},
"config": {
@ -369,7 +369,7 @@ const config = {
}
],
"previewFeatures": [],
"sourceFilePath": "C:\\Users\\Rother\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"sourceFilePath": "C:\\Users\\Chris\\fork\\ironie-nextjs\\prisma\\schema.prisma",
"isCustomOutput": true
},
"relativeEnvPaths": {