feat: přepínání režimů a systému vítězství v Tetrisu
CI / Generate TypeScript types (push) Canceled after 0s
CI / Server unit tests (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Build client (push) Canceled after 0s
CI / Playwright E2E tests (push) Canceled after 0s
CI / Build and push Docker image (push) Canceled after 0s
CI / Notify (push) Canceled after 0s

This commit is contained in:
mates committed 2026-10-19 16:49:56 +02:00
1 parent ed64fc60e4
commit 297cb80662
5 files changed
+277 -39

No files matched your search

+111 -19
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Modal, Table } from "react-bootstrap";
import { Button, Form, Modal, Table } from "react-bootstrap";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
@@ -10,6 +10,9 @@ import "./TetrisGame.scss";
// Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
type TetrisPlayer = { login: string, board: string, score: number, lines: number, alive: boolean, place?: number };
type TetrisTargeting = 'random' | 'even' | 'payback';
type TetrisWinMode = 'survival' | 'score';
type TetrisSettings = { targeting: TetrisTargeting, winMode: TetrisWinMode, timeLimit: number };
type TetrisState = {
phase: TetrisPhase,
host?: string,
@@ -17,11 +20,30 @@ type TetrisState = {
seed?: number,
countdown?: number,
minPlayers: number,
settings: TetrisSettings,
remainingMs?: number,
};
type GarbageEvent = { target: string, from: string, lines: number };
const OPEN_EVENT = 'luncher:open-tetris';
const TARGETING_LABELS: Record<TetrisTargeting, { label: string, help: string }> = {
random: { label: 'Náhodně (Random)', help: 'útok jde náhodnému soupeři' },
even: { label: 'Rovnoměrně (Even)', help: 'útok jde soupeři, který zatím dostal nejméně odpadu' },
payback: { label: 'Oplácení (Payback)', help: 'útok jde tomu, kdo vás naposledy napadl, jinak rovnoměrně' },
};
const WIN_MODE_LABELS: Record<TetrisWinMode, string> = {
survival: 'Poslední ve hře (Last man standing)',
score: 'Nejvyšší skóre',
};
/** Musí odpovídat TIME_LIMITS na serveru (s). */
const TIME_LIMITS = [120, 180, 300];
const formatTime = (ms: number) => {
const total = Math.ceil(ms / 1000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
};
/** Otevře okno Tetrisu. */
export const openTetrisGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
@@ -115,6 +137,9 @@ export default function TetrisGame() {
const boardRef = useRef<HTMLCanvasElement>(null);
const nextRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<TetrisPhase>('idle');
/** Konec hry na skóre v čase prohlížeče (ze zbývajícího času, ať nevadí rozdíl hodin se serverem). */
const deadline = useRef<number | undefined>(undefined);
const [now, setNow] = useState(() => Date.now());
const icon = useGameIcon('tetris');
const send = useCallback((event: string, extra?: object) => {
@@ -128,7 +153,10 @@ export default function TetrisGame() {
}, [send]);
useEffect(() => {
const onState = (s: TetrisState) => setState(s);
const onState = (s: TetrisState) => {
deadline.current = s.remainingMs !== undefined ? Date.now() + s.remainingMs : undefined;
setState(s);
};
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('tetris:sync');
@@ -226,6 +254,13 @@ export default function TetrisGame() {
return () => cancelAnimationFrame(raf);
}, [practice, phase, open, sendSnapshot, state?.seed]);
// Odpočet zbývajícího času hry na skóre
useEffect(() => {
if (phase !== 'playing' || state?.settings.winMode !== 'score') return;
const id = window.setInterval(() => setNow(Date.now()), 500);
return () => window.clearInterval(id);
}, [phase, state?.settings.winMode]);
// Heartbeat pro server (viz HEARTBEAT_MS)
useEffect(() => {
if (practice || phase !== 'playing' || !isPlayer) return;
@@ -287,6 +322,46 @@ export default function TetrisGame() {
const showReturn = !open && isPlayer && inGame;
const others = practice ? [] : (state?.players ?? []).filter(p => !(isPlayer && p.login === login));
const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
const settings = state?.settings;
const isHost = login === state?.host;
const remaining = !practice && phase === 'playing' && deadline.current !== undefined
? Math.max(0, deadline.current - now) : undefined;
const configure = (change: Partial<TetrisSettings>) => send('tetris:configure', change);
const renderSettings = () => settings && (
<div className="tetris-settings">
<Form.Group>
<Form.Label>Míření odpadu</Form.Label>
{isHost ? (
<Form.Select size="sm" value={settings.targeting}
onChange={e => configure({ targeting: e.target.value as TetrisTargeting })}>
{Object.entries(TARGETING_LABELS).map(([value, { label }]) => <option key={value} value={value}>{label}</option>)}
</Form.Select>
) : <div><strong>{TARGETING_LABELS[settings.targeting].label}</strong></div>}
<Form.Text>{TARGETING_LABELS[settings.targeting].help}</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Vítěz</Form.Label>
{isHost ? (
<Form.Select size="sm" value={settings.winMode}
onChange={e => configure({ winMode: e.target.value as TetrisWinMode })}>
{Object.entries(WIN_MODE_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</Form.Select>
) : <div><strong>{WIN_MODE_LABELS[settings.winMode]}</strong></div>}
</Form.Group>
{settings.winMode === 'score' && (
<Form.Group>
<Form.Label>Délka hry</Form.Label>
{isHost ? (
<Form.Select size="sm" value={settings.timeLimit}
onChange={e => configure({ timeLimit: Number(e.target.value) })}>
{TIME_LIMITS.map(t => <option key={t} value={t}>{t / 60} min</option>)}
</Form.Select>
) : <div><strong>{settings.timeLimit / 60} min</strong></div>}
</Form.Group>
)}
</div>
);
const renderPlaying = () => (
<div className="tetris-layout">
@@ -299,6 +374,12 @@ export default function TetrisGame() {
{flash && <div className="tetris-flash">{flash}</div>}
</div>
<div className="tetris-side">
{remaining !== undefined && (
<>
<div className="tetris-label">Zbývá</div>
<div className="tetris-value">{formatTime(remaining)}</div>
</>
)}
<div className="tetris-label">Další</div>
<canvas ref={nextRef} width={100} height={60} />
<div className="tetris-label">Skóre</div>
@@ -335,8 +416,9 @@ export default function TetrisGame() {
return (
<p>
Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz,
pošle 1, 2 nebo 4 odpadní řádky soupeři, který jich zatím dostal nejméně. Smazáním řádků
lze zároveň odrazit odpad, který na vás čeká. Vyhrává poslední hráč ve hře.
pošle soupeři 1, 2 nebo 4 odpadní řádky. Smazáním řádků lze zároveň odrazit odpad,
který na vás čeká. Zakladatel lobby volí, komu útoky půjdou a zda vyhrává poslední
hráč ve hře, nebo nejvyšší skóre po časovém limitu.
Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe.
</p>
);
@@ -347,6 +429,7 @@ export default function TetrisGame() {
<ul className="tetris-players">
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
</ul>
{renderSettings()}
</>
);
case 'countdown':
@@ -354,21 +437,30 @@ export default function TetrisGame() {
return renderPlaying();
case 'finished':
return (
<Table size="sm" className="tetris-results">
<thead>
<tr><th>#</th><th>Hráč</th><th>Skóre</th><th>Řádky</th></tr>
</thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login}>
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
<td>{p.login}{p.login === login && ' (vy)'}</td>
<td>{p.score}</td>
<td>{p.lines}</td>
</tr>
))}
</tbody>
</Table>
<>
{settings && (
<p className="text-muted">
Hodnocení: {WIN_MODE_LABELS[settings.winMode]}
{settings.winMode === 'score' && ` (${settings.timeLimit / 60} min)`}
{' · '}míření: {TARGETING_LABELS[settings.targeting].label}
</p>
)}
<Table size="sm" className="tetris-results">
<thead>
<tr><th>#</th><th>Hráč</th><th>Skóre</th><th>Řádky</th></tr>
</thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login}>
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
<td>{p.login}{p.login === login && ' (vy)'}</td>
<td>{p.score}</td>
<td>{p.lines}</td>
</tr>
))}
</tbody>
</Table>
</>
);
}
};