Live-Ergebnisse: Stream-Seite, Anzeige, automatischer Mapwechsel
- Stream-Übersicht unter /stream/<Zuschauercode> (1920x1080, Lobby, Veto, Spielstand) - Live-Stand, Rundenverlauf und Serienstand auf Match-Seite und Übersicht - Parser mit echten AMP-Logs geprüft: Game Over mit leerer Map-Gruppe, Match_Start vor der Aufwärmphase, veraltete Seitenzuordnung nach der Halbzeit - Nach Map-Ende automatisch nächste Map bzw. Abschluss der entschiedenen Serie - LOG_DEBUG zur Fehlersuche, README und .env.example ergänzt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5fade769e7
commit
43dc5b837a
20 files changed
+901
-44
No files matched your search
@@ -106,7 +106,13 @@ export default function MatchPage() {
|
||||
<div className="versus">
|
||||
<h1>
|
||||
<span className="team-a">{state.teams.A.name}</span>
|
||||
<span className="vs">vs.</span>
|
||||
{state.series.length > 1 ? (
|
||||
<span className="series-total" title="Serienstand (gewonnene Maps)">
|
||||
{state.seriesScore.A}<span className="colon">:</span>{state.seriesScore.B}
|
||||
</span>
|
||||
) : (
|
||||
<span className="vs">vs.</span>
|
||||
)}
|
||||
<span className="team-b">{state.teams.B.name}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -54,10 +54,19 @@ function MatchRow({ match: m }) {
|
||||
<Link to={`/m/${m.id}`} className="match-row">
|
||||
<div className="match-row-teams">
|
||||
<span className="team-a">{m.teamA}</span>
|
||||
<span className="vs">vs.</span>
|
||||
{m.seriesScore && m.format !== 'bo1' ? (
|
||||
<span className="row-score" title="Serienstand">{m.seriesScore.A}:{m.seriesScore.B}</span>
|
||||
) : (
|
||||
<span className="vs">vs.</span>
|
||||
)}
|
||||
<span className="team-b">{m.teamB}</span>
|
||||
</div>
|
||||
<div className="match-row-meta">
|
||||
{m.currentMap && (
|
||||
<span className={m.currentMap.finished ? '' : 'row-live'}>
|
||||
{m.currentMap.finished ? '' : <span className="live-badge">Live</span>} {m.currentMap.name} {m.currentMap.score.A}:{m.currentMap.score.B}
|
||||
</span>
|
||||
)}
|
||||
<span>{m.modeLabel}</span>
|
||||
<span>{FORMAT_LABEL[m.format]}</span>
|
||||
{m.group === 'open' && <span>{m.players.A + m.players.B}/{m.players.teamSize * 2} Spieler</span>}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { api } from '../lib/api.js';
|
||||
import { FORMAT_LABEL } from '../lib/labels.js';
|
||||
import { useMatch } from '../lib/useMatch.js';
|
||||
import MapImage from '../components/MapImage.jsx';
|
||||
import SideIcon from '../components/SideIcon.jsx';
|
||||
import RoundStrip from '../components/RoundStrip.jsx';
|
||||
import '../stream.css';
|
||||
|
||||
const STAGE_W = 1920;
|
||||
const STAGE_H = 1080;
|
||||
|
||||
/**
|
||||
* Vollbild-Übersicht für den Stream: /stream/<Zuschauercode>
|
||||
* Optional ?bg=transparent für eine transparente OBS-Browserquelle.
|
||||
*/
|
||||
export default function StreamPage() {
|
||||
const { code } = useParams();
|
||||
const [matchId, setMatchId] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const transparent = new URLSearchParams(window.location.search).get('bg') === 'transparent';
|
||||
|
||||
useEffect(() => {
|
||||
api('/access', { method: 'POST', body: { code } })
|
||||
.then((info) => {
|
||||
if (info.access !== 'spectator') throw new Error('Für die Stream-Ansicht wird der Zuschauercode benötigt.');
|
||||
setMatchId(info.matchId);
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, [code]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('stream-transparent', transparent);
|
||||
document.documentElement.classList.add('stream-mode');
|
||||
return () => document.documentElement.classList.remove('stream-mode', 'stream-transparent');
|
||||
}, [transparent]);
|
||||
|
||||
return (
|
||||
<ScaledStage>
|
||||
{error ? <StreamMessage title="Stream-Ansicht nicht verfügbar" text={error} />
|
||||
: matchId ? <StreamContent matchId={matchId} code={code} />
|
||||
: <StreamMessage title="Verbinde …" />}
|
||||
</ScaledStage>
|
||||
);
|
||||
}
|
||||
|
||||
/** Feste 1920×1080-Bühne, auf die Fenstergröße skaliert. */
|
||||
function ScaledStage({ children }) {
|
||||
const ref = useRef(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
useLayoutEffect(() => {
|
||||
const fit = () => setScale(Math.min(window.innerWidth / STAGE_W, window.innerHeight / STAGE_H));
|
||||
fit();
|
||||
window.addEventListener('resize', fit);
|
||||
return () => window.removeEventListener('resize', fit);
|
||||
}, []);
|
||||
return (
|
||||
<div className="stream-viewport">
|
||||
<div ref={ref} className="stream-stage" style={{ width: STAGE_W, height: STAGE_H, transform: `translate(-50%, -50%) scale(${scale})` }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamMessage({ title, text }) {
|
||||
return (
|
||||
<div className="st-message">
|
||||
<img src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||
<h1>{title}</h1>
|
||||
{text && <p>{text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useClock() {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return now;
|
||||
}
|
||||
|
||||
function StreamContent({ matchId, code }) {
|
||||
const { state, connected } = useMatch(matchId, code);
|
||||
const now = useClock();
|
||||
if (!state) return <StreamMessage title="Lade Match …" />;
|
||||
|
||||
const { teams } = state;
|
||||
const current = state.series.find((s) => s.number === state.currentMapNumber);
|
||||
const playing = current?.live?.status === 'live' && current.status !== 'done';
|
||||
const phase = state.status === 'lobby' ? 'lobby' : state.status === 'veto' ? 'veto' : 'match';
|
||||
const showSeries = state.series.length > 1;
|
||||
// Im Laufband nur, was Zuschauer interessiert – keine technischen Servermeldungen
|
||||
const tickerEvent = state.events.find((e) => ['veto', 'score', 'series', 'join', 'captain', 'status'].includes(e.type));
|
||||
|
||||
return (
|
||||
<div className={`st-root phase-${phase} ${phase !== 'lobby' && state.series.length > 0 ? 'has-maps' : 'no-maps'}`}>
|
||||
<header className="st-top">
|
||||
<img className="st-logo" src="/brand/maxlan-logo.webp" alt="MaxLAN e.V." />
|
||||
<div className="st-meta">
|
||||
<span>Counter-Strike 2</span>
|
||||
<span>{state.modeLabel}</span>
|
||||
<span>{FORMAT_LABEL[state.format]}</span>
|
||||
</div>
|
||||
<div className="st-clock">
|
||||
{playing && <span className="st-live">Live</span>}
|
||||
{!connected && connected !== null && <span className="st-offline">Verbindung …</span>}
|
||||
<time>{now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</time>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="st-hero">
|
||||
<div className="st-team st-team-a">
|
||||
<h1>{teams.A.name}</h1>
|
||||
</div>
|
||||
<div className="st-center">
|
||||
<span className="st-slash" aria-hidden="true" />
|
||||
{showSeries && phase === 'match' ? (
|
||||
<div className="st-series" aria-label="Serienstand">
|
||||
<span className="a">{state.seriesScore.A}</span>
|
||||
<span className="sep">–</span>
|
||||
<span className="b">{state.seriesScore.B}</span>
|
||||
<small>Maps</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="st-vs">vs</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="st-team st-team-b">
|
||||
<h1>{teams.B.name}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="st-main">
|
||||
{phase === 'lobby' && <LobbyScene state={state} />}
|
||||
{phase === 'veto' && <VetoScene state={state} />}
|
||||
{phase === 'match' && <MatchScene state={state} current={current} />}
|
||||
</section>
|
||||
|
||||
{phase !== 'lobby' && state.series.length > 0 && (
|
||||
<section className="st-maps">
|
||||
{state.series.map((s) => <SeriesCard key={s.number} s={s} state={state} />)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<footer className="st-ticker">
|
||||
<span className="st-ticker-label">{phaseLabel(state)}</span>
|
||||
<span className="st-ticker-text" key={tickerEvent?.at}>{tickerEvent?.message ?? 'Warten auf die Teams'}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function phaseLabel(state) {
|
||||
if (state.status === 'lobby') return 'Lobby';
|
||||
if (state.status === 'veto') return 'Map-Veto';
|
||||
if (state.status === 'finished') return 'Endstand';
|
||||
if (state.status === 'cancelled') return 'Abgebrochen';
|
||||
return 'Match';
|
||||
}
|
||||
|
||||
function LobbyScene({ state }) {
|
||||
return (
|
||||
<div className="st-lobby">
|
||||
{['A', 'B'].map((key) => {
|
||||
const team = state.teams[key];
|
||||
return (
|
||||
<div key={key} className={`st-roster st-roster-${key}`}>
|
||||
<h2>{team.readiness.ready ? 'Bereit' : `${team.readiness.count} / ${team.teamSize} Spieler`}</h2>
|
||||
<ul>
|
||||
{team.players.map((p) => (
|
||||
<li key={p.id} className={p.ready ? 'is-ready' : ''}>
|
||||
<span>{p.nickname}</span>
|
||||
{p.isCaptain && <em>Captain</em>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="st-lobby-note">Das Map-Veto beginnt gleich</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VetoScene({ state }) {
|
||||
const { veto, teams } = state;
|
||||
const current = veto?.current;
|
||||
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
||||
const action = current && ({ ban: 'bannt', pick: 'wählt', side: 'wählt die Startseite auf' })[current.type];
|
||||
return (
|
||||
<div className="st-veto">
|
||||
{current && (
|
||||
<div className={`st-turn st-turn-${current.team}`}>
|
||||
<strong>{teams[current.team].name}</strong> {action}
|
||||
{current.type === 'side' ? ` ${mapsById[current.map]?.name}` : ' eine Map'}
|
||||
</div>
|
||||
)}
|
||||
<div className="st-veto-grid" style={{ '--count': veto.maps.length }}>
|
||||
{veto.maps.map((m) => (
|
||||
<div key={m.id} className={`st-vmap status-${m.status} ${m.by ? `by-${m.by}` : ''} ${current?.map === m.id ? 'is-side' : ''}`}>
|
||||
<MapImage map={mapsById[m.id]} />
|
||||
<span className="st-vmap-name">{mapsById[m.id]?.name}</span>
|
||||
<span className="st-vmap-status">
|
||||
{m.status === 'banned' && `Bann ${teams[m.by].name}`}
|
||||
{m.status === 'picked' && `Pick ${teams[m.by].name}`}
|
||||
{m.status === 'decider' && 'Decider'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchScene({ state, current }) {
|
||||
const { teams } = state;
|
||||
if (!current) return <div className="st-waiting">Server wird vorbereitet</div>;
|
||||
const map = state.maps.find((m) => m.id === current.map);
|
||||
const live = current.live;
|
||||
const sides = live?.sides ?? (current.ctTeam ? { CT: current.ctTeam, T: current.ctTeam === 'A' ? 'B' : 'A' } : null);
|
||||
const sideOf = (team) => (sides ? (sides.CT === team ? 'CT' : 'T') : null);
|
||||
const finished = current.status === 'done';
|
||||
const waiting = !live || live.status === 'warmup';
|
||||
|
||||
return (
|
||||
<div className="st-board" style={{ '--map': map?.color ?? '#556' }}>
|
||||
<MapImage map={map} className="st-board-art" />
|
||||
<div className="st-board-head">
|
||||
<span>Map {current.number}{state.series.length > 1 ? ` von ${state.series.length}` : ''}</span>
|
||||
<strong>{current.mapName}</strong>
|
||||
<span className="st-board-state">{finished ? 'Endstand' : waiting ? 'Aufwärmphase' : `Runde ${(live?.rounds.length ?? 0) + 1}`}</span>
|
||||
</div>
|
||||
<div className="st-score">
|
||||
<div className={`st-score-team a ${current.winner === 'A' ? 'won' : ''}`}>
|
||||
{sideOf('A') && <SideIcon side={sideOf('A')} size={64} />}
|
||||
<span className="num">{current.score.A}</span>
|
||||
</div>
|
||||
<span className="st-score-colon">:</span>
|
||||
<div className={`st-score-team b ${current.winner === 'B' ? 'won' : ''}`}>
|
||||
<span className="num">{current.score.B}</span>
|
||||
{sideOf('B') && <SideIcon side={sideOf('B')} size={64} />}
|
||||
</div>
|
||||
</div>
|
||||
{finished && current.winner && <div className="st-winner">{teams[current.winner].name} gewinnt {current.mapName}</div>}
|
||||
<div className="st-rounds">
|
||||
<RoundStrip rounds={live?.rounds ?? []} maxRounds={state.maxRounds} teams={teams} size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesCard({ s, state }) {
|
||||
const map = state.maps.find((m) => m.id === s.map);
|
||||
const isCurrent = s.number === state.currentMapNumber && state.status === 'live';
|
||||
const playing = isCurrent && s.live?.status === 'live' && s.status !== 'done';
|
||||
return (
|
||||
<div className={`st-mapcard is-${s.status} ${playing ? 'is-playing' : ''}`} style={{ '--map': map?.color ?? '#556' }}>
|
||||
<MapImage map={map} />
|
||||
<span className="st-mapcard-no">Map {s.number}</span>
|
||||
<strong>{s.mapName}</strong>
|
||||
<span className="st-mapcard-foot">
|
||||
{s.status === 'done' || playing ? (
|
||||
<span className="st-mapcard-score">
|
||||
<b className={s.winner === 'A' ? 'won a' : 'a'}>{s.score.A}</b>
|
||||
<i>:</i>
|
||||
<b className={s.winner === 'B' ? 'won b' : 'b'}>{s.score.B}</b>
|
||||
</span>
|
||||
) : (
|
||||
<span className="st-mapcard-origin">{s.pickedBy ? `Pick ${state.teams[s.pickedBy].name}` : 'Decider'}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user