Files
Cs2-Mapveto/client/src/pages/MatchPage.jsx
T

307 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../lib/api.js';
import { FORMAT_LABEL, ROLE_LABEL, serverLabel } from '../lib/labels.js';
import { clearAccess, getPlayerToken, getToken, setAccess, setPlayerToken } from '../lib/session.js';
import { useMatch } from '../lib/useMatch.js';
import TeamCard from '../components/TeamCard.jsx';
import VetoBoard from '../components/VetoBoard.jsx';
import ServerPanel from '../components/ServerPanel.jsx';
import OrganizerPanel from '../components/OrganizerPanel.jsx';
import SeriesList from '../components/SeriesList.jsx';
import EventLog from '../components/EventLog.jsx';
import MapImage from '../components/MapImage.jsx';
import SelfJoin from '../components/SelfJoin.jsx';
import ChatBox from '../components/ChatBox.jsx';
export default function MatchPage() {
const { id } = useParams();
const [token, setToken] = useState(() => {
// Organisator-Link für ein weiteres Gerät: /m/<id>#org=<token>
const hash = new URLSearchParams(window.location.hash.slice(1));
const org = hash.get('org');
if (org) {
setAccess(id, { token: org, kind: 'organizer' });
window.history.replaceState(null, '', window.location.pathname);
}
return getToken(id);
});
// Spieler-Token des Organisators, wenn er im selben Browser selbst mitspielt
const [playerToken, setPlayerTokenState] = useState(() => getPlayerToken(id));
const { state, error, connected } = useMatch(id, token, playerToken);
const [busy, setBusy] = useState(null);
const [toast, setToast] = useState(null);
// Gespeicherter Zugang ist ungültig geworden (z. B. aus dem Team entfernt)
const lostAccess = state && token && state.viewer.role === 'public';
useEffect(() => {
if (lostAccess) {
clearAccess(id);
setToken(null);
setToast({ tone: 'error', text: 'Dein gespeicherter Zugang ist nicht mehr gültig. Tritt mit deinem Teamcode erneut bei.' });
}
}, [lostAccess, id]);
// Organisator war Spieler, ist es aber nicht mehr (Team verlassen oder entfernt)
// justJoined: bis der erste Zustand mit Spieler ankommt, ist ein Zustand ohne Spieler noch veraltet
const [justJoined, setJustJoined] = useState(false);
useEffect(() => {
if (state?.viewer.playerId) setJustJoined(false);
}, [state]);
const lostPlayer = state && playerToken && !justJoined && state.viewer.isOrganizer && !state.viewer.playerId;
useEffect(() => {
if (lostPlayer) {
setPlayerToken(id, null);
setPlayerTokenState(null);
}
}, [lostPlayer, id]);
const joinAsOrganizer = useCallback((newPlayerToken) => {
setJustJoined(true);
setPlayerToken(id, newPlayerToken);
setPlayerTokenState(newPlayerToken);
}, [id]);
useEffect(() => {
if (!toast) return undefined;
const t = setTimeout(() => setToast(null), 6000);
return () => clearTimeout(t);
}, [toast]);
/** Führt eine Aktion aus; verhindert Doppelklicks, solange sie läuft. */
const act = useCallback(async (path, body, success) => {
if (busy) return;
setBusy(path);
try {
await api(`/matches/${id}/${path}`, { method: 'POST', body: body ?? {}, token, playerToken });
if (success) setToast({ tone: 'ok', text: success });
} catch (err) {
setToast({ tone: 'error', text: err.message });
} finally {
setBusy(null);
}
}, [busy, id, token, playerToken]);
if (error && !state) {
return (
<div className="empty">
<h2>{error.code === 'not_found' ? 'Match nicht gefunden' : 'Fehler'}</h2>
<p className="muted">{error.error}</p>
<Link to="/" className="btn">Zur Übersicht</Link>
</div>
);
}
if (!state) return <p className="muted">Verbinde mit Lobby …</p>;
const { viewer } = state;
const isOrg = viewer.isOrganizer;
const server = serverLabel(state.server);
const ctx = { state, act, busy, isOrg, viewer, matchId: id, onJoined: joinAsOrganizer };
return (
<div className="match">
{connected === false && <div className="reconnect">Verbindung unterbrochen – stelle wieder her …</div>}
{toast && <div className={`toast toast-${toast.tone}`} role="status">{toast.text}</div>}
<header className="match-head">
<div className="versus">
<h1>
<span className="team-a">{state.teams.A.name}</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>
<dl className="match-facts">
<div><dt>Modus</dt><dd>{state.modeLabel}</dd></div>
<div><dt>Serie</dt><dd>{FORMAT_LABEL[state.format]}</dd></div>
<div><dt>Status</dt><dd>{state.statusLabel}</dd></div>
<div><dt>Server</dt><dd className={`tone-${server.tone}`}>{server.text}</dd></div>
<div><dt>Du bist</dt><dd>{describeViewer(viewer, state.teams)}</dd></div>
</dl>
</header>
{viewer.role === 'public' && <PublicNotice />}
<div className={`match-grid ${state.status !== 'lobby' ? 'stage-wide' : ''}`}>
<TeamCard team={state.teams.A} side="A" {...ctx} />
<section className="stage">
<Stage {...ctx} />
</section>
<TeamCard team={state.teams.B} side="B" {...ctx} />
</div>
{viewer.role !== 'public' && (
<div className="match-lower">
{state.chat && <ChatBox chat={state.chat} matchId={id} token={token} playerToken={playerToken} />}
<ServerPanel {...ctx} />
{isOrg && <OrganizerPanel {...ctx} token={token} />}
<EventLog events={state.events} />
</div>
)}
</div>
);
}
/** z. B. "Organisator, Captain bei Nordlicht" oder "Spieler bei Hafenkante" */
function describeViewer(viewer, teams) {
const team = viewer.team ? teams[viewer.team].name : null;
const asPlayer = team ? `${viewer.isCaptain ? 'Captain' : 'Spieler'} bei ${team}` : null;
if (viewer.isOrganizer) return asPlayer ? `Organisator, ${asPlayer}` : 'Organisator';
return asPlayer ?? ROLE_LABEL[viewer.role];
}
function Stage(ctx) {
const { state, isOrg, act, busy } = ctx;
if (state.viewer.role === 'public' && state.status === 'lobby') {
return <div className="stage-note"><h2>{state.statusLabel}</h2><p className="muted">Das Veto und der Spielstand erscheinen hier, sobald das Match startet.</p></div>;
}
if (state.status === 'lobby') {
const { A, B } = state.teams;
const missing = [A, B].filter((t) => !t.readiness.ready);
return (
<div className="stage-note">
<h2>{missing.length === 0 ? 'Beide Teams sind bereit' : 'Warten auf beide Teams'}</h2>
{missing.length > 0 && (
<ul className="missing-list">
{missing.map((t) => <li key={t.key}><strong className={`team-${t.key.toLowerCase()}`}>{t.name}:</strong> {readinessGap(t, state.requireFullTeams)}</li>)}
</ul>
)}
{state.vetoStart === 'knife' && <KnifeRound state={state} />}
{isOrg && state.vetoStart === 'knife' ? (
<div className="knife-decide">
<p>Wer hat die Messerrunde gewonnen? Dieses Team beginnt das Veto.</p>
<div className="row">
{['A', 'B'].map((t) => (
<button key={t} className={`btn btn-lg knife-team-${t.toLowerCase()}`} disabled={!state.canStartVeto || !!busy}
onClick={() => act('veto/start', { firstTeam: t }, `Veto gestartet – ${state.teams[t].name} beginnt`)}>
{state.teams[t].name} beginnt
</button>
))}
</div>
{!state.canStartVeto && <p className="small muted">Die Knöpfe werden aktiv, sobald beide Teams bereit sind.</p>}
</div>
) : isOrg ? (
<>
<button className="btn btn-primary btn-lg" disabled={!state.canStartVeto || !!busy}
onClick={() => act('veto/start', null, 'Veto gestartet')}>
Veto starten
</button>
{!state.canStartVeto && <p className="small muted">Der Knopf wird aktiv, sobald beide Teams bereit sind.</p>}
</>
) : state.vetoStart === 'knife' ? (
<p className="small muted">Nach der Messerrunde legt der Organisator fest, welches Team das Veto beginnt.</p>
) : (
<p className="small muted">
{missing.length === 0
? 'Der Organisator kann das Veto jetzt starten.'
: 'Sobald beide Teams bereit sind, startet der Organisator das Veto.'}
</p>
)}
{isOrg && !state.viewer.playerId && <SelfJoin state={state} matchId={ctx.matchId} onJoined={ctx.onJoined} />}
<MapPoolPreview maps={state.maps} />
</div>
);
}
if (state.status === 'veto') return <VetoBoard {...ctx} />;
return (
<>
<SeriesList {...ctx} />
<VetoBoard {...ctx} compact />
</>
);
}
/** Sagt konkret, was einem Team noch fehlt, damit es als bereit gilt. */
/**
* Seitenwahl per Messerrunde: freie Messer-Server vorschlagen, passende Teamgröße zuerst.
* Frei = niemand drauf (oder Spielerzahl unbekannt).
*/
function KnifeRound({ state }) {
const [servers, setServers] = useState(null);
useEffect(() => {
let alive = true;
const load = () => api('/knife-servers').then((res) => alive && setServers(res.servers)).catch(() => alive && setServers([]));
load();
const t = setInterval(load, 10_000);
return () => {
alive = false;
clearInterval(t);
};
}, []);
const free = (servers ?? []).filter((k) => !k.playerCount);
const matching = free.filter((k) => k.mode === state.mode);
const shown = matching.length ? matching : free;
return (
<div className="knife-round">
<h3>Seitenwahl per Messerrunde</h3>
<p className="small">
Beide Teams gehen zusammen auf einen freien Messer-Server und spielen eine Runde nur mit Messer. Danach legt der Organisator fest,
welches Team das Veto beginnt.
</p>
{servers === null ? (
<p className="small muted">Suche freie Messer-Server …</p>
) : shown.length === 0 ? (
<p className="small tone-pending">Gerade ist kein Messer-Server frei. Fragt die Turnierleitung, oder wartet, bis einer frei wird.</p>
) : (
<>
{!matching.length && <p className="small muted">Kein freier Server mit eurer Teamgröße, hier die anderen freien:</p>}
<ul className="knife-list">
{shown.map((k) => (
<li key={k.id}>
<strong>{k.name}</strong>
{k.modeShort && <span className="knife-mode">{k.modeShort}</span>}
<span className="muted small">{k.mapName}</span>
<code>{k.connect}</code>
<a className="btn btn-sm btn-primary" href={k.steamUrl}>Verbinden</a>
</li>
))}
</ul>
</>
)}
</div>
);
}
function readinessGap(team, requireFull) {
const r = team.readiness;
const gaps = [];
if (!r.enough) {
const need = requireFull ? team.teamSize - r.count : 1;
gaps.push(requireFull
? `noch ${need} von ${team.teamSize} Spielern fehlen (Team muss vollständig sein)`
: 'mindestens ein Spieler fehlt');
}
if (r.count > 0 && !r.allReady) gaps.push('noch nicht alle haben „bereit“ geklickt');
if (r.count > 0 && !r.hasCaptain) gaps.push('kein Captain');
return gaps.join(', ');
}
function MapPoolPreview({ maps }) {
return (
<div className="pool-preview" aria-label="Map-Pool">
{maps.map((m) => (
<span key={m.id} className="pool-chip" style={{ '--map': m.color ?? '#556' }}>
<MapImage map={m} />
<span className="pool-chip-name">{m.name}</span>
</span>
))}
</div>
);
}
function PublicNotice() {
return (
<div className="alert">
Du siehst dieses Match ohne Zugang. Veto, Serienstand und Ergebnisse sind öffentlich.
Teamdetails und Verbindungsdaten gibt es nur für Teilnehmer.
</div>
);
}