feat: skryté multiplayerové minihry přes websocket
CI / Generate TypeScript types (push) Canceled after 7s
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

Minihry se otevírají trojklikem na logo a napsáním kódu hry
("help" vypíše seznam do konzole prohlížeče):
- reakce: reakční hra, 3 kola, pořadí podle součtu časů
- snake: had pro více hráčů, simulaci drží server
- tetris: battle Tetris s odpadními řádky a lokálním tréninkem
- pong: Pong 1v1, ovládání myší i klávesami, bot se 4 obtížnostmi
- kresleni: kreslení a hádání jídel z dnešních menu

Ikony her se řídí zvoleným vzhledem. Stav her je v paměti serveru,
předpokládá se jedna replika.
This commit is contained in:
batmanisko committed 2026-10-12 14:25:45 +02:00
1 parent abeee19259
commit 6cb1214f08
28 files changed
+5050 -2

No files matched your search

+433
View File
@@ -0,0 +1,433 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Modal, Table } from "react-bootstrap";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { TetrisSession, WIDTH, HEIGHT, pieceCells, Piece } from "../games/tetrisEngine";
import { useGameIcon } from "../games/themeIcons";
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 TetrisState = {
phase: TetrisPhase,
host?: string,
players: TetrisPlayer[],
seed?: number,
countdown?: number,
minPlayers: number,
};
type GarbageEvent = { target: string, from: string, lines: number };
const OPEN_EVENT = 'luncher:open-tetris';
/** Otevře okno Tetrisu. */
export const openTetrisGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
/** Barvy kostek I, O, T, S, Z, J, L a odpadu (index = hodnota buňky). */
const COLORS = ['', '#22d3ee', '#facc15', '#a855f7', '#22c55e', '#ef4444', '#3b82f6', '#f97316', '#6b7280'];
const MEDALS = ['🥇', '🥈', '🥉'];
const CELL = 26;
const MINI_CELL = 9;
/** Jak často se soupeřům posílá deska s padající kostkou (ms). */
const SNAPSHOT_MS = 250;
/**
* Deska se posílá i časovačem — běží i v neaktivním tabu (na rozdíl od requestAnimationFrame),
* takže server pozná, že hráč pořád hraje. Musí být výrazně kratší než PLAYER_TIMEOUT_MS na serveru.
*/
const HEARTBEAT_MS = 2000;
const KEYS: Record<string, 'left' | 'right' | 'down' | 'rotate' | 'rotateBack' | 'drop'> = {
ArrowLeft: 'left', KeyA: 'left',
ArrowRight: 'right', KeyD: 'right',
ArrowDown: 'down', KeyS: 'down',
ArrowUp: 'rotate', KeyW: 'rotate', KeyX: 'rotate',
KeyZ: 'rotateBack', KeyY: 'rotateBack',
Space: 'drop',
};
function drawCell(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, value: number) {
ctx.fillStyle = COLORS[value];
ctx.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
}
/** Vykreslí desku zadanou řetězcem (soupeři) nebo polem (vlastní hra). */
function drawBoard(canvas: HTMLCanvasElement, cells: ArrayLike<number | string>, size: number, piece?: Piece | null, ghost?: Piece | null) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < WIDTH * HEIGHT; i++) {
const v = Number(cells[i]);
if (v) drawCell(ctx, i % WIDTH, Math.floor(i / WIDTH), size, v);
}
if (ghost) {
ctx.globalAlpha = 0.25;
pieceCells(ghost).forEach(([x, y]) => y >= 0 && drawCell(ctx, x, y, size, ghost.type + 1));
ctx.globalAlpha = 1;
}
if (piece) {
pieceCells(piece).forEach(([x, y]) => y >= 0 && drawCell(ctx, x, y, size, piece.type + 1));
}
}
/** Náhled příští kostky. */
function drawNext(canvas: HTMLCanvasElement, type: number) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const cells = pieceCells({ type, rot: 0, x: 0, y: 0 });
const minX = Math.min(...cells.map(c => c[0]));
const minY = Math.min(...cells.map(c => c[1]));
cells.forEach(([x, y]) => drawCell(ctx, x - minX + 0.5, y - minY + 0.5, 20, type + 1));
}
/** Malá deska soupeře. */
function MiniBoard({ player }: { player: TetrisPlayer }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (ref.current) drawBoard(ref.current, player.board, MINI_CELL);
}, [player.board]);
return (
<div className={`tetris-mini ${player.alive ? '' : 'dead'}`}>
<canvas ref={ref} width={WIDTH * MINI_CELL} height={HEIGHT * MINI_CELL} />
<div className="tetris-mini-name">{player.login}{!player.alive && ' 💀'}</div>
<div className="tetris-mini-score">{player.score}</div>
</div>
);
}
/** Multiplayerový Tetris (battle) — vlastní deska běží v prohlížeči, server koordinuje hru. */
export default function TetrisGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<TetrisState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [flash, setFlash] = useState<string | null>(null);
/** Překreslení postranních údajů (skóre, řádky) po změně v session. */
const [, setVersion] = useState(0);
/** Trénink: hra jen v prohlížeči, bez serveru a soupeřů. */
const [practice, setPractice] = useState(false);
const sessionRef = useRef<TetrisSession | null>(null);
const sessionSeed = useRef<number | undefined>(undefined);
const boardRef = useRef<HTMLCanvasElement>(null);
const nextRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<TetrisPhase>('idle');
const icon = useGameIcon('tetris');
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
const sendSnapshot = useCallback(() => {
const s = sessionRef.current;
if (s) send('tetris:update', { board: s.snapshot(), score: s.score, lines: s.lines });
}, [send]);
useEffect(() => {
const onState = (s: TetrisState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('tetris:sync');
socket.on('tetris:state', onState);
socket.on('tetris:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('tetris:state', onState);
socket.off('tetris:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
// Průběžné desky soupeřů a útoky dostávají jen klienti s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('tetris:watch');
watch();
socket.on('connect', watch);
return () => {
socket.off('connect', watch);
socket.emit('tetris:unwatch');
};
}, [open]);
useEffect(() => {
const onGarbage = (e: GarbageEvent) => {
if (e.target !== login || !sessionRef.current?.alive) return;
sessionRef.current.receiveGarbage(e.lines);
setFlash(`⚠️ +${e.lines} od ${e.from}`);
window.setTimeout(() => setFlash(null), 1500);
};
socket.on('tetris:garbage', onGarbage);
return () => { socket.off('tetris:garbage', onGarbage); };
}, [login]);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'countdown' || phase === 'playing';
// Hráčům se okno otevře samo, jakmile hra začne
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
setPractice(false);
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Start vlastní hry se seedem od serveru
useEffect(() => {
if (practice || phase !== 'playing' || !isPlayer || state?.seed === undefined || sessionSeed.current === state.seed) return;
sessionSeed.current = state.seed;
sessionRef.current = new TetrisSession(state.seed, {
onLock: cleared => {
if (cleared >= 2) send('tetris:cleared', { count: cleared });
sendSnapshot();
setVersion(v => v + 1);
},
onDeath: () => {
sendSnapshot();
send('tetris:died');
setVersion(v => v + 1);
},
});
setVersion(v => v + 1);
}, [practice, phase, isPlayer, state?.seed, send, sendSnapshot]);
// Herní smyčka: gravitace, vykreslení a průběžné posílání desky (trénink se při zavřeném okně pozastaví)
useEffect(() => {
if (practice ? !open : phase !== 'playing') return;
if (!sessionRef.current) return;
let raf = 0;
let lastFall = performance.now();
let lastSnapshot = 0;
const loop = (now: number) => {
const s = sessionRef.current;
if (!s) return;
if (s.alive && now - lastFall >= s.gravityMs) {
s.tick();
lastFall = now;
}
if (!practice && s.alive && now - lastSnapshot >= SNAPSHOT_MS) {
sendSnapshot();
lastSnapshot = now;
}
if (boardRef.current) drawBoard(boardRef.current, s.board, CELL, s.active, s.ghost);
if (nextRef.current) drawNext(nextRef.current, s.nextType);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [practice, phase, open, sendSnapshot, state?.seed]);
// Heartbeat pro server (viz HEARTBEAT_MS)
useEffect(() => {
if (practice || phase !== 'playing' || !isPlayer) return;
const id = window.setInterval(() => {
if (sessionRef.current?.alive) sendSnapshot();
}, HEARTBEAT_MS);
return () => window.clearInterval(id);
}, [practice, phase, isPlayer, sendSnapshot]);
// Ovládání
useEffect(() => {
if (!open || !(practice || (phase === 'playing' && isPlayer))) return;
const onKey = (e: KeyboardEvent) => {
const action = KEYS[e.code];
const s = sessionRef.current;
if (!action || !s) return;
e.preventDefault();
if (!s.alive) return;
switch (action) {
case 'left': s.move(-1); break;
case 'right': s.move(1); break;
case 'down': s.softDrop(); break;
case 'rotate': if (!e.repeat) s.rotate(1); break;
case 'rotateBack': if (!e.repeat) s.rotate(-1); break;
case 'drop': if (!e.repeat) s.hardDrop(); break;
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, practice, phase, isPlayer]);
// Po skončení hry se session zahodí, ať se příště začíná načisto
useEffect(() => {
if (!practice && (phase === 'lobby' || phase === 'idle')) {
sessionRef.current = null;
sessionSeed.current = undefined;
}
}, [practice, phase]);
const startPractice = useCallback(() => {
sessionSeed.current = undefined;
sessionRef.current = new TetrisSession(Math.floor(Math.random() * 2 ** 31), {
onLock: () => setVersion(v => v + 1),
onDeath: () => setVersion(v => v + 1),
});
setPractice(true);
setVersion(v => v + 1);
}, []);
const stopPractice = useCallback(() => {
sessionRef.current = null;
setPractice(false);
}, []);
if (!login) return null;
const session = sessionRef.current;
const showInvite = !open && phase === 'lobby' && !isPlayer;
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 renderPlaying = () => (
<div className="tetris-layout">
{(isPlayer || practice) && (
<div className="tetris-own">
<div className="tetris-board-wrap">
<canvas ref={boardRef} className="tetris-board" width={WIDTH * CELL} height={HEIGHT * CELL} />
{!practice && phase === 'countdown' && <div className="tetris-overlay">{state?.countdown}</div>}
{session && !session.alive && <div className="tetris-overlay small">Konec 💀</div>}
{flash && <div className="tetris-flash">{flash}</div>}
</div>
<div className="tetris-side">
<div className="tetris-label">Další</div>
<canvas ref={nextRef} width={100} height={60} />
<div className="tetris-label">Skóre</div>
<div className="tetris-value">{session?.score ?? 0}</div>
<div className="tetris-label">Řádky</div>
<div className="tetris-value">{session?.lines ?? 0}</div>
<div className="tetris-label">Úroveň</div>
<div className="tetris-value">{session?.level ?? 0}</div>
<div className="tetris-help">
←→ / AD posun<br />↑ / W otočit<br />↓ / S dolů<br />mezerník shodit
</div>
</div>
</div>
)}
{others.length > 0 && (
<div className="tetris-opponents">
{others.map(p => <MiniBoard key={p.login} player={p} />)}
</div>
)}
</div>
);
const renderBody = () => {
if (practice) {
return (
<>
<p className="text-muted text-center">🎯 Trénink — hra jen pro vás, nikdo ji nevidí.</p>
{renderPlaying()}
</>
);
}
switch (phase) {
case 'idle':
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 náhodnému soupeři 1, 2 nebo 4 odpadní řádky. Vyhrává poslední hráč ve hře.
Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe.
</p>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
<ul className="tetris-players">
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
</ul>
</>
);
case 'countdown':
case 'playing':
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>
);
}
};
const renderFooter = () => {
const buttons = [];
if (practice) {
if (session && !session.alive) {
buttons.push(<Button key="again" onClick={startPractice}>Znovu</Button>);
}
buttons.push(<Button key="stop" variant="outline-secondary" onClick={stopPractice}>Ukončit trénink</Button>);
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
}
// Trénink jde spustit, kdykoli zrovna nehrajete online hru
if (!(isPlayer && (phase === 'lobby' || inGame))) {
buttons.push(<Button key="practice" variant="outline-primary" onClick={startPractice}>Trénink</Button>);
}
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('tetris:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('tetris:join')}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tetris:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('tetris:start')}
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 1)}>Spustit hru</Button>);
}
}
}
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
};
return (
<>
{showInvite && (
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na Tetris — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
{icon} Tetris běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="tetris-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Tetris</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{renderBody()}
{error && <div className="text-danger mt-2">{error}</div>}
</Modal.Body>
<Modal.Footer>{renderFooter()}</Modal.Footer>
</Modal>
</>
);
}