Die verbleibende Zeit des Veto-Schritts erscheint als Leiste mit Sekundenanzeige, in den letzten 10 Sekunden rot. Der Server schickt die Schrittdauer mit, damit die Leiste zu VETO_STEP_SECONDS passt.
159 lines
6.8 KiB
React
159 lines
6.8 KiB
React
import { useEffect, useState } from 'react';
|
||
import { clock } from '../lib/labels.js';
|
||
import MapImage from './MapImage.jsx';
|
||
import SideIcon from './SideIcon.jsx';
|
||
|
||
const STEP_SHORT = { ban: 'Ban', pick: 'Pick', side: 'Seite', decider: 'Decider' };
|
||
|
||
export default function VetoBoard({ state, viewer, act, busy, compact = false }) {
|
||
const { veto, teams } = state;
|
||
const { current } = veto;
|
||
const [selected, setSelected] = useState(null);
|
||
useEffect(() => setSelected(null), [current?.index]);
|
||
const left = useCountdown(veto.remainingMs);
|
||
|
||
const mapsById = Object.fromEntries(state.maps.map((m) => [m.id, m]));
|
||
const yourTurn = !!current && viewer.isCaptain && viewer.team === current.team;
|
||
const choosingMap = yourTurn && (current.type === 'ban' || current.type === 'pick');
|
||
const sideMap = current?.type === 'side' ? mapsById[current.map] : null;
|
||
|
||
const submitMap = () => act('veto/action', { stepIndex: current.index, map: selected });
|
||
const submitSide = (side) => act('veto/action', { stepIndex: current.index, side });
|
||
|
||
return (
|
||
<div className={`veto ${compact ? 'veto-compact' : ''}`}>
|
||
{current && (
|
||
<div className={`turn turn-${current.team} ${yourTurn ? 'is-you' : ''}`} aria-live="polite">
|
||
<span className="turn-team">{teams[current.team].name}</span>
|
||
<span className="turn-action">
|
||
{current.type === 'ban' && 'bannt eine Map'}
|
||
{current.type === 'pick' && 'wählt eine Map'}
|
||
{current.type === 'side' && `wählt die Startseite auf ${sideMap?.name}`}
|
||
</span>
|
||
<span className="turn-hint">
|
||
{yourTurn
|
||
? 'Du bist am Zug.'
|
||
: viewer.team === current.team
|
||
? 'Dein Captain entscheidet.'
|
||
: !viewer.team
|
||
? 'Live-Ansicht – nur Captains können wählen.'
|
||
: 'Das andere Team ist am Zug.'}
|
||
</span>
|
||
{left !== null && <TimerBar left={left} totalMs={(veto.stepSeconds || 30) * 1000} />}
|
||
</div>
|
||
)}
|
||
{!current && veto.done && !compact && <div className="turn turn-done">Veto abgeschlossen</div>}
|
||
|
||
<div className="map-grid" role="list">
|
||
{veto.maps.map((m) => {
|
||
const meta = mapsById[m.id];
|
||
const selectable = choosingMap && m.status === 'available';
|
||
const isSel = selected === m.id;
|
||
return (
|
||
<button
|
||
key={m.id}
|
||
type="button"
|
||
role="listitem"
|
||
className={`map-tile status-${m.status} ${m.by ? `by-${m.by}` : ''} ${isSel ? `is-selected sel-${current.type}` : ''} ${sideMap?.id === m.id ? 'is-side' : ''}`}
|
||
style={{ '--map': meta?.color ?? '#556' }}
|
||
disabled={!selectable || !!busy}
|
||
onClick={() => setSelected(isSel ? null : m.id)}
|
||
aria-pressed={isSel}
|
||
aria-label={`${meta.name}: ${statusText(m, teams)}`}
|
||
>
|
||
<MapImage map={meta} />
|
||
<span className="map-name">{meta.name}</span>
|
||
<span className="map-status">{statusText(m, teams)}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{choosingMap && (
|
||
<div className="veto-confirm">
|
||
<button className={`btn btn-lg ${current.type === 'ban' ? 'btn-danger' : 'btn-primary'}`} disabled={!selected || !!busy} onClick={submitMap}>
|
||
{selected
|
||
? `${mapsById[selected].name} ${current.type === 'ban' ? 'bannen' : 'wählen'}`
|
||
: `Map zum ${current.type === 'ban' ? 'Bannen' : 'Wählen'} antippen`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{yourTurn && current.type === 'side' && (
|
||
<div className="side-choice">
|
||
<MapImage map={sideMap} className="side-choice-art" />
|
||
<div className="side-choice-body">
|
||
<p>Auf welcher Seite startet <strong>{teams[viewer.team].name}</strong> auf <strong>{sideMap?.name}</strong>?</p>
|
||
<div className="side-options">
|
||
<button className="side-option side-option-ct" disabled={!!busy} onClick={() => submitSide('CT')}>
|
||
<SideIcon side="CT" size={44} />
|
||
<span className="side-option-name">Counter-Terrorists</span>
|
||
<span className="side-option-sub">Start auf CT, Gegner beginnt als T</span>
|
||
</button>
|
||
<button className="side-option side-option-t" disabled={!!busy} onClick={() => submitSide('T')}>
|
||
<SideIcon side="T" size={44} />
|
||
<span className="side-option-name">Terrorists</span>
|
||
<span className="side-option-sub">Start auf T, Gegner beginnt als CT</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<ol className="step-track" aria-label="Veto-Ablauf">
|
||
{veto.steps.map((s) => (
|
||
<li key={s.index} className={`step step-${s.type} ${s.team ? `step-${s.team}` : ''} ${s.done ? 'done' : ''} ${current?.index === s.index ? 'now' : ''}`}
|
||
title={s.team ? `${teams[s.team].name}: ${STEP_SHORT[s.type]}` : 'Übrig gebliebene Map'}>
|
||
<span>{STEP_SHORT[s.type]}</span>
|
||
<small>{s.team ? teams[s.team].name : 'automatisch'}</small>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
|
||
{veto.history.length > 0 && !compact && (
|
||
<ol className="veto-history">
|
||
{veto.history.map((h) => (
|
||
<li key={h.index} className={`hist-${h.type} ${h.team ? `hist-${h.team}` : ''}`}>
|
||
<time>{clock(h.at)}</time> {h.text}
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function statusText(m, teams) {
|
||
switch (m.status) {
|
||
case 'banned': return `Gebannt von ${teams[m.by].name}`;
|
||
case 'picked': return `Gewählt von ${teams[m.by].name}`;
|
||
case 'decider': return 'Entscheidungsmap';
|
||
default: return 'Verfügbar';
|
||
}
|
||
}
|
||
|
||
/** Leiste für die verbleibende Zeit des Veto-Schritts. In den letzten 10 Sekunden wird sie rot. */
|
||
function TimerBar({ left, totalMs }) {
|
||
const seconds = Math.ceil(left / 1000);
|
||
const percent = Math.max(0, Math.min(100, (left / totalMs) * 100));
|
||
return (
|
||
<div className={`turn-timer ${left <= 10_000 ? 'is-low' : ''}`} role="timer" aria-label={`Noch ${seconds} Sekunden`}>
|
||
<div className="turn-bar"><span style={{ width: `${percent}%` }} /></div>
|
||
<span className="turn-seconds">{seconds} s</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Verbleibende Zeit des Veto-Schritts: der Server setzt sie bei jeder Änderung, danach zählt die Anzeige lokal herunter. */
|
||
function useCountdown(ms) {
|
||
const [left, setLeft] = useState(ms ?? null);
|
||
useEffect(() => {
|
||
setLeft(ms ?? null);
|
||
if (ms == null) return undefined;
|
||
const started = Date.now();
|
||
const t = setInterval(() => setLeft(Math.max(0, ms - (Date.now() - started))), 250);
|
||
return () => clearInterval(t);
|
||
}, [ms]);
|
||
return left;
|
||
}
|