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

+145
View File
@@ -0,0 +1,145 @@
// Pozvánka Kreslení nad pozvánkami ostatních her, aby se nepřekrývaly
.duel-invite.draw-invite {
bottom: 224px;
}
.drawing-game-modal {
.draw-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 10px;
font-size: 1.05rem;
}
.draw-hint {
// Mezery mezi slovy v masce se nesmí slít, jinak nejde poznat počet slov
white-space: pre;
font-family: monospace;
font-size: 1.4rem;
letter-spacing: 0.15em;
}
.draw-word strong {
font-size: 1.3rem;
}
.draw-timer.urgent {
color: #ef4444;
font-weight: 700;
}
.draw-layout {
display: flex;
gap: 16px;
align-items: stretch;
}
.draw-canvas-col {
flex: 1 1 auto;
min-width: 0;
}
.draw-canvas {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
background: #fff;
touch-action: none;
&.active {
cursor: crosshair;
}
}
.draw-tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.draw-color {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid rgba(127, 127, 127, 0.5);
padding: 0;
&.selected {
outline: 3px solid var(--luncher-primary, #198754);
outline-offset: 1px;
}
}
.draw-size {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
border: 1px solid rgba(127, 127, 127, 0.5);
background: transparent;
span {
display: block;
border-radius: 50%;
background: currentColor;
}
&.selected {
outline: 3px solid var(--luncher-primary, #198754);
}
}
.draw-sep {
width: 8px;
}
.draw-side {
flex: 0 0 260px;
display: flex;
flex-direction: column;
gap: 8px;
}
.draw-scores {
list-style: none;
padding: 0;
margin: 0;
li {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.guessed {
color: #22c55e;
}
}
.draw-chat {
flex: 1 1 auto;
min-height: 200px;
max-height: 360px;
overflow-y: auto;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 6px;
padding: 6px 8px;
font-size: 0.9rem;
.system {
color: #22c55e;
font-style: italic;
}
}
.draw-players {
columns: 2;
}
}
+429
View File
@@ -0,0 +1,429 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Form, Modal, Table } from "react-bootstrap";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import "./DrawingGame.scss";
// Typy musí odpovídat server/src/drawingGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
type ChatMessage = { login?: string, text: string, system?: boolean };
type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
type DrawState = {
phase: DrawPhase,
host?: string,
players: DrawPlayer[],
drawer?: string,
round: number,
totalRounds: number,
hint?: string,
answer?: string,
remainingMs?: number,
messages: ChatMessage[],
minPlayers: number,
};
const OPEN_EVENT = 'luncher:open-drawing';
/** Otevře okno Kreslení. */
export const openDrawingGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
const CANVAS_W = 800;
const CANVAS_H = 600;
/** Souřadnice tahů jsou 0–1000 v obou osách, nezávisle na velikosti plátna. */
const COORD_MAX = 1000;
const COLORS = ['#111827', '#ef4444', '#f97316', '#facc15', '#22c55e', '#3b82f6', '#a855f7', '#92400e', '#ffffff'];
const SIZES = [3, 8, 18];
/** Jak často se rozkreslený tah posílá ostatním (ms). */
const FLUSH_MS = 40;
const MEDALS = ['🥇', '🥈', '🥉'];
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke) {
const sx = CANVAS_W / COORD_MAX;
const sy = CANVAS_H / COORD_MAX;
ctx.strokeStyle = stroke.color;
ctx.fillStyle = stroke.color;
ctx.lineWidth = stroke.size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const [first, ...rest] = stroke.points;
if (rest.length === 0) {
ctx.beginPath();
ctx.arc(first[0] * sx, first[1] * sy, stroke.size / 2, 0, Math.PI * 2);
ctx.fill();
return;
}
ctx.beginPath();
ctx.moveTo(first[0] * sx, first[1] * sy);
rest.forEach(([x, y]) => ctx.lineTo(x * sx, y * sy));
ctx.stroke();
}
function clearCanvas(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
}
/** Kreslení — jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. */
export default function DrawingGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<DrawState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [word, setWord] = useState<string | null>(null);
const [color, setColor] = useState(COLORS[0]);
const [size, setSize] = useState(SIZES[1]);
const [guess, setGuess] = useState('');
const [secondsLeft, setSecondsLeft] = useState(0);
const canvasRef = useRef<HTMLCanvasElement>(null);
const chatRef = useRef<HTMLDivElement>(null);
const prevPhase = useRef<DrawPhase>('idle');
/** Rozkreslený tah: body čekající na odeslání a poslední odeslaný bod pro navázání. */
const pending = useRef<{ points: [number, number][], start: boolean } | null>(null);
const drawingRef = useRef(false);
const icon = useGameIcon('drawing');
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
const ctx = () => canvasRef.current?.getContext('2d') ?? null;
useEffect(() => {
const onState = (s: DrawState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('draw:sync');
socket.on('draw:state', onState);
socket.on('draw:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('draw:state', onState);
socket.off('draw:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
const isDrawer = !!login && state?.phase === 'drawing' && state.drawer === login;
const isDrawerRef = useRef(isDrawer);
isDrawerRef.current = isDrawer;
// Kresba a slovo chodí jen klientům s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('draw:watch', { token: getToken() });
const onStrokes = (strokes: Stroke[]) => {
const c = ctx();
if (!c) return;
clearCanvas(c);
strokes.forEach(s => drawStroke(c, s));
};
// Vlastní tahy kreslíř vykresluje hned lokálně
const onStroke = (s: Stroke) => {
const c = ctx();
if (c && !isDrawerRef.current) drawStroke(c, s);
};
const onClear = () => {
const c = ctx();
if (c) clearCanvas(c);
};
const onWord = (w: string) => setWord(w);
socket.on('draw:strokes', onStrokes);
socket.on('draw:stroke', onStroke);
socket.on('draw:clear', onClear);
socket.on('draw:word', onWord);
socket.on('connect', watch);
watch();
return () => {
socket.off('draw:strokes', onStrokes);
socket.off('draw:stroke', onStroke);
socket.off('draw:clear', onClear);
socket.off('draw:word', onWord);
socket.off('connect', watch);
socket.emit('draw:unwatch');
};
}, [open]);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'drawing' || phase === 'reveal';
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'drawing' && isPlayer) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Slovo patří jen k aktuálnímu kolu
useEffect(() => {
if (!isDrawer) setWord(null);
}, [isDrawer, state?.round]);
// Plátno se po vykreslení (nové kolo, otevření okna) vyplní bílou a znovu vyžádá kresba
useEffect(() => {
if (open && inGame) {
const c = ctx();
if (c) clearCanvas(c);
socket.emit('draw:watch', { token: getToken() });
}
}, [open, inGame]);
// Odpočet kola počítaný lokálně od posledního stavu ze serveru
useEffect(() => {
if (phase !== 'drawing' || state?.remainingMs === undefined) {
setSecondsLeft(0);
return;
}
const endsAt = Date.now() + state.remainingMs;
const update = () => setSecondsLeft(Math.max(0, Math.ceil((endsAt - Date.now()) / 1000)));
update();
const id = window.setInterval(update, 250);
return () => window.clearInterval(id);
}, [phase, state?.remainingMs]);
useEffect(() => {
chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight });
}, [state?.messages.length]);
// Průběžné odesílání rozkresleného tahu
useEffect(() => {
if (!isDrawer) return;
const id = window.setInterval(() => {
const p = pending.current;
if (!p || p.points.length === 0 || (p.points.length === 1 && !p.start)) return;
send('draw:stroke', { color, size, points: p.points, start: p.start });
// Další dávka začne posledním bodem, aby čára navazovala
pending.current = drawingRef.current ? { points: [p.points[p.points.length - 1]], start: false } : null;
}, FLUSH_MS);
return () => window.clearInterval(id);
}, [isDrawer, color, size, send]);
const toCoords = (e: React.PointerEvent<HTMLCanvasElement>): [number, number] => {
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.round(((e.clientX - rect.left) / rect.width) * COORD_MAX);
const y = Math.round(((e.clientY - rect.top) / rect.height) * COORD_MAX);
return [Math.min(COORD_MAX, Math.max(0, x)), Math.min(COORD_MAX, Math.max(0, y))];
};
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawer) return;
e.currentTarget.setPointerCapture(e.pointerId);
drawingRef.current = true;
const point = toCoords(e);
pending.current = { points: [point], start: true };
const c = ctx();
if (c) drawStroke(c, { color, size, points: [point], start: true });
};
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawer || !drawingRef.current || !pending.current) return;
const point = toCoords(e);
const prev = pending.current.points[pending.current.points.length - 1];
if (prev && prev[0] === point[0] && prev[1] === point[1]) return;
pending.current.points.push(point);
const c = ctx();
if (c && prev) drawStroke(c, { color, size, points: [prev, point], start: false });
};
const onPointerUp = () => {
drawingRef.current = false;
};
const submitGuess = (e: React.FormEvent) => {
e.preventDefault();
if (!guess.trim()) return;
send('draw:guess', { text: guess });
setGuess('');
};
if (!login) return null;
const me = state?.players.find(p => p.login === login);
const canChat = isPlayer && !isDrawer && !(phase === 'drawing' && me?.guessed);
const ranked = [...(state?.players ?? [])].sort((a, b) => b.score - a.score);
const showInvite = !open && phase === 'lobby' && !isPlayer;
const showReturn = !open && isPlayer && inGame;
const renderTopBar = () => {
if (phase === 'reveal' || phase === 'finished') {
return <div className="draw-topbar">Bylo to: <strong>{state?.answer}</strong></div>;
}
return (
<div className="draw-topbar">
<span>Kolo {state?.round}/{state?.totalRounds} · kreslí <strong>{state?.drawer}</strong></span>
{isDrawer
? <span className="draw-word">Nakreslete: <strong>{word ?? '…'}</strong></span>
: <span className="draw-hint">{state?.hint}</span>}
<span className={secondsLeft <= 10 ? 'draw-timer urgent' : 'draw-timer'}>⏱ {secondsLeft} s</span>
</div>
);
};
const renderGame = () => (
<>
{renderTopBar()}
<div className="draw-layout">
<div className="draw-canvas-col">
<canvas
ref={canvasRef}
className={`draw-canvas ${isDrawer ? 'active' : ''}`}
width={CANVAS_W}
height={CANVAS_H}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
/>
{isDrawer && (
<div className="draw-tools">
{COLORS.map(c => (
<button key={c} className={`draw-color ${c === color ? 'selected' : ''}`}
style={{ background: c }} onClick={() => setColor(c)} aria-label={`Barva ${c}`} />
))}
<span className="draw-sep" />
{SIZES.map(s => (
<button key={s} className={`draw-size ${s === size ? 'selected' : ''}`} onClick={() => setSize(s)}
aria-label={`Tloušťka ${s}`}>
<span style={{ width: s, height: s }} />
</button>
))}
<span className="draw-sep" />
<Button size="sm" variant="outline-secondary" onClick={() => send('draw:clear')}>Smazat</Button>
</div>
)}
</div>
<div className="draw-side">
<ul className="draw-scores">
{ranked.map(p => (
<li key={p.login} className={p.guessed && phase === 'drawing' ? 'guessed' : undefined}>
<span>{p.login === state?.drawer && phase === 'drawing' ? '✏️ ' : ''}{p.login}{p.login === login && ' (vy)'}</span>
<strong>{p.score}</strong>
</li>
))}
</ul>
{renderChat()}
</div>
</div>
</>
);
const renderChat = () => (
<>
<div className="draw-chat" ref={chatRef}>
{state?.messages.map((m, i) => (
<div key={i} className={m.system ? 'system' : undefined}>
{m.login && <strong>{m.login}: </strong>}{m.text}
</div>
))}
</div>
<Form onSubmit={submitGuess}>
<Form.Control
value={guess}
onChange={e => setGuess(e.target.value)}
placeholder={isDrawer ? 'Kreslíte…' : canChat ? 'Váš tip…' : 'Uhodli jste ✔'}
disabled={!canChat}
maxLength={100}
onKeyDown={e => e.stopPropagation()}
/>
</Form>
</>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<p>
Jeden kreslí jídlo z dnešních menu, ostatní hádají v chatu. Stačí trefit hlavní slovo
(např. „guláš"), diakritika nevadí. Čím dřív uhodnete, tím víc bodů; kreslíř dostává body
za každého, kdo uhodl. Každý kreslí jednou.
</p>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
<ul className="draw-players">
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
</ul>
{renderChat()}
</>
);
case 'drawing':
case 'reveal':
return renderGame();
case 'finished':
return (
<>
<Table size="sm">
<thead><tr><th>#</th><th>Hráč</th><th>Body</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>
</tr>
))}
</tbody>
</Table>
</>
);
}
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('draw:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('draw:join')}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('draw:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('draw:start')}
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
}
}
}
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
};
return (
<>
{showInvite && (
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na kreslení — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite draw-invite" onClick={() => setOpen(true)}>
{icon} Kreslení běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="drawing-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Kreslení</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>
</>
);
}
+13 -1
View File
@@ -2,6 +2,12 @@ import { useEffect, useState } from "react";
import { Navbar, Nav, NavDropdown, Modal, Button } from "react-bootstrap";
import { useAuth } from "../context/auth";
import SettingsModal from "./modals/SettingsModal";
import ReactionDuel from "./ReactionDuel";
import SnakeGame from "./SnakeGame";
import TetrisGame from "./TetrisGame";
import PongGame from "./PongGame";
import DrawingGame from "./DrawingGame";
import { useMinigameSecret } from "../hooks/useMinigameSecret";
import { useSettings, ThemePreference } from "../context/settings";
import HuePicker from "./HuePicker";
import PizzaCalculatorModal from "./modals/PizzaCalculatorModal";
@@ -39,6 +45,7 @@ export default function Header({ choices, dayIndex }: Props) {
const [clearMockModalOpen, setClearMockModalOpen] = useState<boolean>(false);
const effectiveDark = settings?.effectiveDark ?? false;
const minigameSecret = useMinigameSecret("/");
useEffect(() => {
if (!auth?.login) return;
@@ -147,7 +154,7 @@ export default function Header({ choices, dayIndex }: Props) {
}
return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/">Luncher</Navbar.Brand>
<Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav">
@@ -195,6 +202,11 @@ export default function Header({ choices, dayIndex }: Props) {
</NavDropdown>
</Nav>
</Navbar.Collapse>
<ReactionDuel />
<SnakeGame />
<TetrisGame />
<PongGame />
<DrawingGame />
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
+66
View File
@@ -0,0 +1,66 @@
// Pozvánka Pongu nad pozvánkami ostatních her, aby se nepřekrývaly
.duel-invite.pong-invite {
bottom: 172px;
}
.pong-game-modal {
.pong-scoreboard {
display: flex;
justify-content: center;
align-items: baseline;
gap: 24px;
font-size: 1.1rem;
margin-bottom: 8px;
strong {
font-size: 2rem;
}
}
.pong-board-wrap {
position: relative;
max-width: 800px;
margin: 0 auto;
}
.pong-board {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
}
.pong-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
pointer-events: none;
&.small {
font-size: 2.5rem;
}
}
.pong-bot-picker {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.form-select {
width: auto;
}
}
.pong-status {
text-align: center;
opacity: 0.7;
margin-top: 8px;
}
}
+310
View File
@@ -0,0 +1,310 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Form, Modal } from "react-bootstrap";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import "./PongGame.scss";
// Typy musí odpovídat server/src/pongGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type PongPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
type BotLevel = 'easy' | 'medium' | 'hard' | 'impossible';
type PongPlayer = { login: string, side: 0 | 1, y: number, score: number, bot?: BotLevel };
type PongState = {
phase: PongPhase,
host?: string,
players: PongPlayer[],
ball: { x: number, y: number },
countdown?: number,
winner?: string,
width: number,
height: number,
};
const OPEN_EVENT = 'luncher:open-pong';
/** Otevře okno Pongu. */
export const openPongGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
// Rozměry musí odpovídat serveru
const PADDLE_HEIGHT = 90;
const PADDLE_WIDTH = 12;
const PADDLE_MARGIN = 20;
const BALL_SIZE = 12;
const SIDE_COLORS = ['#60a5fa', '#f472b6'];
/** Jak často se posílá poloha myši (ms). */
const AIM_SEND_MS = 30;
const BOT_LEVELS: { value: BotLevel, label: string }[] = [
{ value: 'easy', label: 'Lehký' },
{ value: 'medium', label: 'Střední' },
{ value: 'hard', label: 'Těžký' },
{ value: 'impossible', label: 'Nemožný' },
];
const UP_KEYS = new Set(['ArrowUp', 'KeyW']);
const DOWN_KEYS = new Set(['ArrowDown', 'KeyS']);
function draw(canvas: HTMLCanvasElement, state: PongState, me?: string) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const scale = canvas.width / state.width;
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, state.width, state.height);
// Středová čára
ctx.fillStyle = '#374151';
for (let y = 0; y < state.height; y += 30) ctx.fillRect(state.width / 2 - 2, y + 5, 4, 18);
for (const p of state.players) {
const x = p.side === 0 ? PADDLE_MARGIN : state.width - PADDLE_MARGIN - PADDLE_WIDTH;
ctx.fillStyle = SIDE_COLORS[p.side];
ctx.fillRect(x, p.y - PADDLE_HEIGHT / 2, PADDLE_WIDTH, PADDLE_HEIGHT);
if (p.login === me) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.strokeRect(x - 1, p.y - PADDLE_HEIGHT / 2 - 1, PADDLE_WIDTH + 2, PADDLE_HEIGHT + 2);
}
}
if (state.phase === 'playing') {
ctx.fillStyle = '#f9fafb';
ctx.beginPath();
ctx.arc(state.ball.x, state.ball.y, BALL_SIZE / 2, 0, Math.PI * 2);
ctx.fill();
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
/** Pong 1v1 — fyziku drží server, klient posílá jen směr pálky. */
export default function PongGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<PongState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<PongPhase>('idle');
const icon = useGameIcon('pong');
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
/** Výška hřiště pro přepočet myši — přes ref, ať se efekt nerestartuje každým snímkem. */
const fieldHeight = useRef(500);
fieldHeight.current = state?.height ?? fieldHeight.current;
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
useEffect(() => {
const onState = (s: PongState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('pong:sync');
socket.on('pong:state', onState);
socket.on('pong:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('pong:state', onState);
socket.off('pong:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
// Snímky hry dostávají jen klienti s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('pong:watch');
watch();
socket.on('connect', watch);
return () => {
socket.off('connect', watch);
socket.emit('pong:unwatch');
};
}, [open]);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'countdown' || phase === 'playing';
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Ovládání: drží se klávesa → pálka jede, puštění → stojí
useEffect(() => {
if (!open || !inGame || !isPlayer) return;
const pressed = new Set<string>();
const update = () => {
const up = [...pressed].some(k => UP_KEYS.has(k));
const down = [...pressed].some(k => DOWN_KEYS.has(k));
send('pong:input', { input: up === down ? 'stop' : up ? 'up' : 'down' });
};
const onDown = (e: KeyboardEvent) => {
if (!UP_KEYS.has(e.code) && !DOWN_KEYS.has(e.code)) return;
e.preventDefault();
if (e.repeat || pressed.has(e.code)) return;
pressed.add(e.code);
update();
};
const onUp = (e: KeyboardEvent) => {
if (!pressed.delete(e.code)) return;
update();
};
const onBlur = () => {
pressed.clear();
update();
};
window.addEventListener('keydown', onDown);
window.addEventListener('keyup', onUp);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onDown);
window.removeEventListener('keyup', onUp);
window.removeEventListener('blur', onBlur);
};
}, [open, inGame, isPlayer, send]);
// Ovládání myší: pálka sleduje výšku kurzoru nad hřištěm (kdekoli v okně, mimo hřiště se ořízne)
useEffect(() => {
if (!open || !inGame || !isPlayer) return;
let latest: number | undefined;
let sent: number | undefined;
const onMove = (e: PointerEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const height = fieldHeight.current;
const y = ((e.clientY - rect.top) / rect.height) * height;
latest = Math.round(Math.min(height, Math.max(0, y)));
};
const id = window.setInterval(() => {
if (latest === undefined || latest === sent) return;
sent = latest;
send('pong:aim', { y: latest });
}, AIM_SEND_MS);
window.addEventListener('pointermove', onMove);
return () => {
window.clearInterval(id);
window.removeEventListener('pointermove', onMove);
};
}, [open, inGame, isPlayer, send]);
useEffect(() => {
if (canvasRef.current && state && phase !== 'idle' && phase !== 'lobby') {
draw(canvasRef.current, state, login);
}
}, [state, phase, login]);
if (!login) return null;
const left = state?.players.find(p => p.side === 0);
const right = state?.players.find(p => p.side === 1);
const showInvite = !open && phase === 'lobby' && !isPlayer && (state?.players.length ?? 0) < 2;
const showReturn = !open && isPlayer && inGame;
const renderBoard = () => state && (
<>
<div className="pong-scoreboard">
<span style={{ color: SIDE_COLORS[0] }}>{left?.login}</span>
<strong>{left?.score ?? 0} : {right?.score ?? 0}</strong>
<span style={{ color: SIDE_COLORS[1] }}>{right?.login}</span>
</div>
<div className="pong-board-wrap">
<canvas ref={canvasRef} className="pong-board" width={state.width} height={state.height} />
{phase === 'countdown' && <div className="pong-overlay">{state.countdown}</div>}
{phase === 'finished' && <div className="pong-overlay small">🏆 {state.winner}</div>}
</div>
<div className="pong-status">{isPlayer ? 'Myš, nebo ↑↓ / W S' : 'Sledujete zápas'}</div>
</>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<p>
Klasický Pong pro dva. Ovládání myší (pálka sleduje kurzor) nebo šipkami / W S, hraje se
do 5 bodů. Když není s kým hrát, přidejte si v lobby bota. Ostatní se mohou dívat.
</p>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>.</p>
<ul>{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}</ul>
{(state?.players.length ?? 0) < 2 && <p className="text-muted">Čeká se na soupeře…</p>}
{login === state?.host && (state?.players.length ?? 0) < 2 && (
<div className="pong-bot-picker">
<span>Nebo hrát proti botovi:</span>
<Form.Select size="sm" value={botLevel} onChange={e => setBotLevel(e.target.value as BotLevel)}>
{BOT_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
</Form.Select>
<Button size="sm" variant="outline-primary" onClick={() => send('pong:addBot', { level: botLevel })}>
Přidat bota
</Button>
</div>
)}
{login === state?.host && state?.players.some(p => p.bot) && (
<Button size="sm" variant="outline-secondary" onClick={() => send('pong:removeBot')}>Odebrat bota</Button>
)}
</>
);
default:
return renderBoard();
}
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('pong:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
if ((state?.players.length ?? 0) < 2) {
buttons.push(<Button key="join" onClick={() => send('pong:join')}>Připojit se</Button>);
}
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('pong:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('pong:start')}
disabled={(state?.players.length ?? 0) < 2}>Spustit hru</Button>);
}
}
}
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
};
return (
<>
{showInvite && (
<button className="duel-invite pong-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> hledá soupeře na Pong — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite pong-invite" onClick={() => setOpen(true)}>
{icon} Pong běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="pong-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Pong</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>
</>
);
}
+114
View File
@@ -0,0 +1,114 @@
.duel-invite {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 1040;
padding: 10px 16px;
border: none;
border-radius: 999px;
background: var(--luncher-primary, #198754);
color: #fff;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
cursor: pointer;
animation: duel-pulse 1.6s ease-in-out infinite;
}
@keyframes duel-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
.reaction-duel-modal {
.duel-arena {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 280px;
border-radius: 12px;
color: #fff;
user-select: none;
cursor: pointer;
touch-action: manipulation;
transition: background-color 0.05s;
&.waiting { background: #b02a37; }
&.go { background: #198754; }
&.done { background: #495057; }
&.early { background: #fd7e14; }
}
.duel-round {
opacity: 0.85;
}
.duel-signal {
font-size: 3rem;
font-weight: 700;
margin: 8px 0;
}
.duel-hint {
opacity: 0.85;
font-size: 0.9rem;
}
.duel-players {
columns: 2;
}
.duel-podium {
display: flex;
justify-content: center;
align-items: flex-end;
gap: 12px;
margin-bottom: 16px;
}
.duel-place {
flex: 0 1 160px;
padding: 12px 8px;
border-radius: 10px;
text-align: center;
background: var(--bs-tertiary-bg);
// Vítěz uprostřed a nejvýš, jako na stupních vítězů
&.place-1 { order: 2; padding-top: 28px; }
&.place-2 { order: 1; }
&.place-3 { order: 3; }
}
.duel-medal {
font-size: 2.2rem;
}
.duel-place-name {
font-weight: 700;
overflow-wrap: anywhere;
}
.duel-place-time {
opacity: 0.75;
font-size: 0.9rem;
}
.duel-last {
text-align: center;
}
.duel-results .duel-loser td {
background: rgba(220, 53, 69, 0.15);
}
}
// Nenápadné potvrzení, že logo přijalo sérii kliknutí a čeká na tajné slovo
.navbar-brand.duel-armed {
display: inline-block;
animation: duel-wiggle 0.35s ease-in-out 2;
}
@keyframes duel-wiggle {
0%, 100% { transform: rotate(0); }
25% { transform: rotate(-4deg); }
75% { transform: rotate(4deg); }
}
+262
View File
@@ -0,0 +1,262 @@
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 { useGameIcon } from "../games/themeIcons";
import "./ReactionDuel.scss";
// Typy musí odpovídat server/src/reactionDuel.ts (stav chodí přes websocket, ne přes OpenAPI)
type DuelPhase = 'idle' | 'lobby' | 'waiting' | 'go' | 'roundResult' | 'finished';
type RoundResult = { ms: number | null, falseStart?: boolean };
type DuelPlayer = { login: string, rounds: RoundResult[], total: number };
type DuelState = {
phase: DuelPhase,
host?: string,
round: number,
totalRounds: number,
players: DuelPlayer[],
loser?: string,
winner?: string,
minPlayers: number,
};
const OPEN_EVENT = 'luncher:open-duel';
/** Otevře okno reakční hry. */
export const openReactionDuel = () => window.dispatchEvent(new Event(OPEN_EVENT));
const MEDALS = ['🥇', '🥈', '🥉'];
const formatResult = (r?: RoundResult) => {
if (!r) return '—';
if (r.falseStart) return 'předčasně';
if (r.ms == null) return 'nekliknuto';
return `${r.ms} ms`;
};
/** Reakční hra — lobby, kola a výsledky; časování drží server. */
export default function ReactionDuel() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<DuelState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
/** Kolo, ve kterém už tento klient kliknul (a zda předčasně). */
const [myClick, setMyClick] = useState<{ round: number, early: boolean } | null>(null);
const prevPhase = useRef<DuelPhase>('idle');
const icon = useGameIcon('reaction');
const send = useCallback((event: string) => {
setError(null);
socket.emit(event, { token: getToken() });
}, []);
useEffect(() => {
const onState = (s: DuelState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('duel:sync');
socket.on('duel:state', onState);
socket.on('duel:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('duel:state', onState);
socket.off('duel:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inRound = phase === 'waiting' || phase === 'go';
// Hráčům se okno otevře samo, jakmile hra začne
useEffect(() => {
if (prevPhase.current === 'lobby' && phase === 'waiting' && isPlayer) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
const click = useCallback(() => {
if (!state || !isPlayer || !inRound || myClick?.round === state.round) return;
setMyClick({ round: state.round, early: state.phase === 'waiting' });
send('duel:click');
}, [state, isPlayer, inRound, myClick, send]);
// Mezerník funguje stejně jako kliknutí
useEffect(() => {
if (!open || !inRound) return;
const onKey = (e: KeyboardEvent) => {
if (e.code === 'Space') {
e.preventDefault();
click();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, inRound, click]);
if (!login) return null;
const showInvite = !open && phase === 'lobby' && !isPlayer;
const showReturn = !open && isPlayer && phase !== 'finished';
const clickedThisRound = myClick?.round === state?.round;
const ranked = [...(state?.players ?? [])].sort((a, b) => a.total - b.total);
const renderArena = () => {
if (!state) return null;
let cls = 'waiting';
let text = 'Počkej…';
if (phase === 'go') {
cls = 'go';
text = 'TEĎ!';
}
if (isPlayer && clickedThisRound) {
cls = myClick?.early ? 'early' : 'done';
text = myClick?.early ? 'Předčasný start! 😬' : 'Kliknuto ✔';
}
return (
<div
className={`duel-arena ${cls}`}
onPointerDown={click}
role="button"
tabIndex={0}
>
<div className="duel-round">Kolo {state.round}/{state.totalRounds}</div>
<div className="duel-signal">{text}</div>
{isPlayer
? <div className="duel-hint">Klikněte nebo zmáčkněte mezerník, až zezelená.</div>
: <div className="duel-hint">Sledujete hru.</div>}
</div>
);
};
const renderResults = (final: boolean) => (
<Table size="sm" className="duel-results">
<thead>
<tr>
<th>#</th>
<th>Hráč</th>
{Array.from({ length: state?.totalRounds ?? 0 }, (_, i) => <th key={i}>{i + 1}. kolo</th>)}
<th>Celkem</th>
</tr>
</thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login} className={final && p.login === state?.loser ? 'duel-loser' : undefined}>
<td>{final && i < 3 ? MEDALS[i] : i + 1}</td>
<td>{p.login}{p.login === login && ' (vy)'}</td>
{Array.from({ length: state?.totalRounds ?? 0 }, (_, r) => <td key={r}>{formatResult(p.rounds[r])}</td>)}
<td><strong>{p.rounds.length ? `${p.total} ms` : '—'}</strong></td>
</tr>
))}
</tbody>
</Table>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<>
<p>
Hra má {state?.totalRounds ?? 3} kola — po náhodné chvíli se plocha rozsvítí zeleně
a počítá se, jak rychle kliknete. Předčasný start nebo nekliknutí znamená trestné 2 s.
Vyhrává nejnižší součet časů.
</p>
<p className="text-muted">Po založení lobby dostanou ostatní online pozvánku.</p>
</>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
<ul className="duel-players">
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
</ul>
{state && state.players.length < state.minPlayers &&
<p className="text-muted">Čeká se na další hráče…</p>}
</>
);
case 'waiting':
case 'go':
return renderArena();
case 'roundResult':
return (
<>
<p>Kolo {state?.round}/{state?.totalRounds} skončilo, další za chvíli…</p>
{renderResults(false)}
</>
);
case 'finished':
return (
<>
<div className="duel-podium">
{ranked.slice(0, 3).map((p, i) => (
<div key={p.login} className={`duel-place place-${i + 1}`}>
<div className="duel-medal">{MEDALS[i]}</div>
<div className="duel-place-name">{p.login}</div>
<div className="duel-place-time">{p.total} ms</div>
</div>
))}
</div>
{state?.loser && ranked.length > 3 &&
<p className="duel-last">🐢 Poslední: <strong>{state.loser}</strong></p>}
{renderResults(true)}
</>
);
}
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('duel:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('duel:join')}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('duel:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('duel:start')}
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
}
}
}
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
};
return (
<>
{showInvite && (
<button className="duel-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na reakční hru — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite" onClick={() => setOpen(true)}>
{icon} Reakční hra běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="lg" className="reaction-duel-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Reakční hra</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>
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
// Pozvánka hada nad pozvánkou reakční hry, aby se nepřekrývaly
.duel-invite.snake-invite {
bottom: 68px;
}
.snake-game-modal {
.snake-board-wrap {
position: relative;
margin: 0 auto;
max-width: 768px;
}
.snake-board {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
image-rendering: pixelated;
}
.snake-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
pointer-events: none;
}
.snake-status {
text-align: center;
opacity: 0.75;
margin: 8px 0;
}
.snake-scores {
max-width: 768px;
margin: 0 auto;
}
.snake-dead td {
opacity: 0.5;
}
.snake-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
}
.snake-players {
columns: 2;
list-style: none;
padding-left: 0;
}
}
+310
View File
@@ -0,0 +1,310 @@
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 { useGameIcon } from "../games/themeIcons";
import "./SnakeGame.scss";
// Typy musí odpovídat server/src/snakeGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type Cell = [number, number];
type Direction = 'up' | 'down' | 'left' | 'right';
type SnakePhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
type SnakePlayer = { login: string, color: number, body: Cell[], alive: boolean, score: number, place?: number };
type SnakeState = {
phase: SnakePhase,
host?: string,
players: SnakePlayer[],
food: Cell[],
tick: number,
maxTicks: number,
countdown?: number,
width: number,
height: number,
minPlayers: number,
};
const OPEN_EVENT = 'luncher:open-snake';
/** Otevře okno hada. */
export const openSnakeGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
const COLORS = ['#4ade80', '#60a5fa', '#f472b6', '#facc15', '#a78bfa', '#fb923c', '#2dd4bf', '#f87171'];
const MEDALS = ['🥇', '🥈', '🥉'];
const TICK_MS = 120;
const KEY_DIRECTIONS: Record<string, Direction> = {
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
KeyW: 'up', KeyS: 'down', KeyA: 'left', KeyD: 'right',
};
/** Vykreslí hrací plochu do canvasu. */
function draw(canvas: HTMLCanvasElement, state: SnakeState, me?: string) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const cell = canvas.width / state.width;
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#1f2937';
for (let x = 0; x < state.width; x++) {
for (let y = 0; y < state.height; y++) {
if ((x + y) % 2 === 0) ctx.fillRect(x * cell, y * cell, cell, cell);
}
}
for (const [x, y] of state.food) {
ctx.fillStyle = '#ef4444';
ctx.beginPath();
ctx.arc((x + 0.5) * cell, (y + 0.5) * cell, cell * 0.35, 0, Math.PI * 2);
ctx.fill();
}
for (const p of state.players) {
const color = COLORS[p.color % COLORS.length];
// Tělo tlumeně, hlava se kreslí zvlášť až nakonec
ctx.fillStyle = color;
ctx.globalAlpha = 0.65;
p.body.slice(1).forEach(([x, y]) => ctx.fillRect(x * cell + 2, y * cell + 2, cell - 4, cell - 4));
ctx.globalAlpha = 1;
if (p.body[0]) drawHead(ctx, p.body, cell, color, p.login === me);
}
}
/** Hlava: plná barva, zaoblená, přes okraj políčka a s očima ve směru pohybu. */
function drawHead(ctx: CanvasRenderingContext2D, body: Cell[], cell: number, color: string, mine: boolean) {
const [x, y] = body[0];
const neck = body[1];
const dx = neck ? Math.sign(x - neck[0]) : 1;
const dy = neck ? Math.sign(y - neck[1]) : 0;
const cx = (x + 0.5) * cell;
const cy = (y + 0.5) * cell;
const size = cell * 1.1;
ctx.fillStyle = color;
ctx.beginPath();
ctx.roundRect(cx - size / 2, cy - size / 2, size, size, cell * 0.35);
ctx.fill();
// Vlastní had má bílý obrys hlavy, ostatní tmavý — hlava se tak odliší od těla i pozadí
ctx.strokeStyle = mine ? '#fff' : '#0b0f19';
ctx.lineWidth = 2;
ctx.stroke();
// Oči: posunuté ve směru pohybu, rozložené kolmo na něj
const forward = cell * 0.18;
const side = cell * 0.22;
for (const s of [-1, 1]) {
const ex = cx + dx * forward + -dy * side * s;
const ey = cy + dy * forward + dx * side * s;
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(ex, ey, cell * 0.14, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(ex + dx * cell * 0.05, ey + dy * cell * 0.05, cell * 0.07, 0, Math.PI * 2);
ctx.fill();
}
}
/** Multiplayerový had — lobby, hrací plocha a výsledky; simulaci drží server. */
export default function SnakeGame() {
const auth = useAuth();
const login = auth?.login;
const [state, setState] = useState<SnakeState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<SnakePhase>('idle');
const icon = useGameIcon('snake');
const send = useCallback((event: string, extra?: object) => {
setError(null);
socket.emit(event, { token: getToken(), ...extra });
}, []);
useEffect(() => {
const onState = (s: SnakeState) => setState(s);
const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true);
const sync = () => socket.emit('snake:sync');
socket.on('snake:state', onState);
socket.on('snake:error', onError);
socket.on('connect', sync);
window.addEventListener(OPEN_EVENT, onOpen);
sync();
return () => {
socket.off('snake:state', onState);
socket.off('snake:error', onError);
socket.off('connect', sync);
window.removeEventListener(OPEN_EVENT, onOpen);
};
}, []);
// Průběžné snímky hry dostávají jen klienti s otevřeným oknem
useEffect(() => {
if (!open) return;
const watch = () => socket.emit('snake:watch');
watch();
socket.on('connect', watch);
return () => {
socket.off('connect', watch);
socket.emit('snake:unwatch');
};
}, [open]);
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) {
setOpen(true);
}
prevPhase.current = phase;
}, [phase, isPlayer]);
// Ovládání šipkami a WSAD
useEffect(() => {
if (!open || !inGame || !isPlayer) return;
const onKey = (e: KeyboardEvent) => {
const dir = KEY_DIRECTIONS[e.code];
if (!dir) return;
e.preventDefault();
if (!e.repeat) send('snake:turn', { dir });
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, inGame, isPlayer, send]);
useEffect(() => {
if (canvasRef.current && state && (inGame || phase === 'finished')) {
draw(canvasRef.current, state, login);
}
}, [state, inGame, phase, login]);
if (!login) return null;
const showInvite = !open && phase === 'lobby' && !isPlayer;
const showReturn = !open && isPlayer && inGame;
const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99) || b.score - a.score);
const secondsLeft = state ? Math.max(0, Math.ceil((state.maxTicks - state.tick) * TICK_MS / 1000)) : 0;
const renderBoard = () => state && (
<div className="snake-board-wrap">
<canvas ref={canvasRef} className="snake-board" width={state.width * 24} height={state.height * 24} />
{phase === 'countdown' && <div className="snake-overlay">{state.countdown}</div>}
</div>
);
const renderScores = () => (
<Table size="sm" className="snake-scores">
<thead>
<tr><th>#</th><th>Hráč</th><th>Jídla</th><th>Délka</th></tr>
</thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login} className={p.alive || phase !== 'playing' ? undefined : 'snake-dead'}>
<td>{phase === 'finished' && i < 3 ? MEDALS[i] : (phase === 'finished' ? i + 1 : '')}</td>
<td>
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
{p.login}{p.login === login && ' (vy)'}{!p.alive && inGame && ' 💀'}
</td>
<td>{p.score}</td>
<td>{p.body.length || '—'}</td>
</tr>
))}
</tbody>
</Table>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<p>
Klasický had pro více hráčů. Ovládání šipkami nebo WSAD. Jezte jídlo a vyhněte se zdem,
sobě i ostatním — čelní srážka zabije oba. Vyhrává ten, kdo přežije nejdéle;
hra trvá nejvýš 3 minuty. Hrát se dá i sólo.
</p>
);
case 'lobby':
return (
<>
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
<ul className="snake-players">
{state?.players.map(p => (
<li key={p.login}>
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
{p.login}{p.login === state.host && ' 👑'}
</li>
))}
</ul>
</>
);
case 'countdown':
case 'playing':
return (
<>
{renderBoard()}
<div className="snake-status">
{isPlayer ? 'Šipky / WSAD' : 'Sledujete hru'} · zbývá {secondsLeft} s
</div>
{renderScores()}
</>
);
case 'finished':
return (
<>
{renderBoard()}
{renderScores()}
</>
);
}
};
const renderFooter = () => {
const buttons = [];
if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('snake:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('snake:join')}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('snake:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('snake: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 snake-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na hada — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
{icon} Had běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="snake-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Had</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>
</>
);
}
+119
View File
@@ -0,0 +1,119 @@
// Pozvánka Tetrisu nad pozvánkami ostatních her, aby se nepřekrývaly
.duel-invite.tetris-invite {
bottom: 120px;
}
.tetris-game-modal {
.tetris-layout {
display: flex;
flex-wrap: wrap;
gap: 24px;
justify-content: center;
align-items: flex-start;
}
.tetris-own {
display: flex;
gap: 16px;
}
.tetris-board-wrap {
position: relative;
}
.tetris-board {
display: block;
border-radius: 6px;
}
.tetris-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 5rem;
font-weight: 700;
color: #fff;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
background: rgba(0, 0, 0, 0.25);
pointer-events: none;
&.small {
font-size: 2.2rem;
}
}
.tetris-flash {
position: absolute;
top: 8px;
left: 0;
right: 0;
text-align: center;
font-weight: 700;
color: #fca5a5;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
pointer-events: none;
}
.tetris-side {
min-width: 110px;
}
.tetris-label {
font-size: 0.8rem;
text-transform: uppercase;
opacity: 0.6;
margin-top: 10px;
}
.tetris-value {
font-size: 1.4rem;
font-weight: 700;
}
.tetris-help {
margin-top: 16px;
font-size: 0.8rem;
opacity: 0.7;
line-height: 1.5;
}
.tetris-opponents {
display: flex;
flex-wrap: wrap;
gap: 12px;
max-width: 440px;
}
.tetris-mini {
text-align: center;
canvas {
display: block;
border-radius: 4px;
}
&.dead canvas {
opacity: 0.35;
}
}
.tetris-mini-name {
font-size: 0.8rem;
font-weight: 600;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tetris-mini-score {
font-size: 0.75rem;
opacity: 0.7;
}
.tetris-players {
columns: 2;
}
}
+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>
</>
);
}
+238
View File
@@ -0,0 +1,238 @@
/**
* Herní logika Tetrisu bez Reactu — deska, kostky, rotace s odrazem od stěn,
* mazání řádků a odpadní řádky od soupeřů.
*/
export const WIDTH = 10;
export const HEIGHT = 20;
/** Hodnota buňky odpadního řádku (1–7 jsou kostky). */
export const GARBAGE = 8;
type Point = [number, number];
/** Kostky I, O, T, S, Z, J, L ve výchozí rotaci a velikost jejich čtverce pro rotaci. */
const SHAPES: { cells: Point[], box: number }[] = [
{ cells: [[0, 1], [1, 1], [2, 1], [3, 1]], box: 4 },
{ cells: [[0, 0], [1, 0], [0, 1], [1, 1]], box: 2 },
{ cells: [[1, 0], [0, 1], [1, 1], [2, 1]], box: 3 },
{ cells: [[1, 0], [2, 0], [0, 1], [1, 1]], box: 3 },
{ cells: [[0, 0], [1, 0], [1, 1], [2, 1]], box: 3 },
{ cells: [[0, 0], [0, 1], [1, 1], [2, 1]], box: 3 },
{ cells: [[2, 0], [0, 1], [1, 1], [2, 1]], box: 3 },
];
/** Předpočítané rotace (po směru hodinových ručiček) pro každou kostku. */
const ROTATIONS: Point[][][] = SHAPES.map(({ cells, box }) => {
const result = [cells];
for (let r = 1; r < 4; r++) {
result.push(result[r - 1].map(([x, y]) => [box - 1 - y, x] as Point));
}
return result;
});
/** Posuny zkoušené při rotaci, když kostka narazí (zjednodušený wall kick). */
const KICKS: Point[] = [[0, 0], [-1, 0], [1, 0], [0, -1], [-2, 0], [2, 0]];
/** Body za smazání 0–4 řádků naráz (násobí se úrovní). */
const LINE_SCORES = [0, 100, 300, 500, 800];
export type Piece = { type: number, rot: number, x: number, y: number };
/** Deterministický generátor (mulberry32) — stejný seed = stejné pořadí kostek u všech. */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6D2B79F5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export const pieceCells = (p: Piece): Point[] => ROTATIONS[p.type][p.rot].map(([x, y]) => [p.x + x, p.y + y]);
export type SessionCallbacks = {
/** Kostka dopadla; `cleared` = počet smazaných řádků. */
onLock: (cleared: number) => void,
onDeath: () => void,
};
/** Jedna rozehraná hra jednoho hráče. */
export class TetrisSession {
board: number[] = new Array(WIDTH * HEIGHT).fill(0);
active: Piece | null = null;
nextType = 0;
score = 0;
lines = 0;
alive = true;
private random: () => number;
private bag: number[] = [];
private pendingGarbage = 0;
constructor(seed: number, private readonly callbacks: SessionCallbacks) {
this.random = mulberry32(seed);
this.nextType = this.draw();
this.spawn();
}
get level() {
return Math.floor(this.lines / 10);
}
/** Interval gravitace v ms — s úrovní zrychluje. */
get gravityMs() {
return Math.max(80, 800 - this.level * 60);
}
/** Pozice "stínu" — kam by kostka dopadla. */
get ghost(): Piece | null {
if (!this.active) return null;
let p = this.active;
while (this.fits({ ...p, y: p.y + 1 })) p = { ...p, y: p.y + 1 };
return p;
}
tick() {
if (!this.alive || !this.active) return;
if (!this.tryMove(0, 1)) this.lock();
}
move(dx: number) {
if (this.alive) this.tryMove(dx, 0);
}
softDrop() {
if (!this.alive) return;
if (this.tryMove(0, 1)) this.score += 1;
else this.lock();
}
hardDrop() {
if (!this.alive || !this.active) return;
let dropped = 0;
while (this.tryMove(0, 1)) dropped++;
this.score += dropped * 2;
this.lock();
}
rotate(dir: 1 | -1) {
if (!this.alive || !this.active) return;
const rot = (this.active.rot + dir + 4) % 4;
for (const [kx, ky] of KICKS) {
const candidate = { ...this.active, rot, x: this.active.x + kx, y: this.active.y + ky };
if (this.fits(candidate)) {
this.active = candidate;
return;
}
}
}
/** Odpadní řádky se přidají až po dopadu aktuální kostky (jako v klasickém battle módu). */
receiveGarbage(lines: number) {
this.pendingGarbage += lines;
}
/** Deska včetně padající kostky (pro soupeře), znaky 0–8. */
snapshot(): string {
const cells = [...this.board];
if (this.active) {
for (const [x, y] of pieceCells(this.active)) {
if (y >= 0) cells[y * WIDTH + x] = this.active.type + 1;
}
}
return cells.join('');
}
private draw(): number {
if (this.bag.length === 0) {
this.bag = [0, 1, 2, 3, 4, 5, 6];
for (let i = this.bag.length - 1; i > 0; i--) {
const j = Math.floor(this.random() * (i + 1));
[this.bag[i], this.bag[j]] = [this.bag[j], this.bag[i]];
}
}
return this.bag.pop()!;
}
private spawn() {
const type = this.nextType;
this.nextType = this.draw();
const piece: Piece = { type, rot: 0, x: type === 1 ? 4 : 3, y: type === 0 ? -1 : 0 };
if (!this.fits(piece)) {
this.active = null;
this.die();
return;
}
this.active = piece;
}
private fits(p: Piece) {
return pieceCells(p).every(([x, y]) =>
x >= 0 && x < WIDTH && y < HEIGHT && (y < 0 || this.board[y * WIDTH + x] === 0));
}
private tryMove(dx: number, dy: number) {
if (!this.active) return false;
const candidate = { ...this.active, x: this.active.x + dx, y: this.active.y + dy };
if (!this.fits(candidate)) return false;
this.active = candidate;
return true;
}
private lock() {
if (!this.active) return;
const cells = pieceCells(this.active);
// Kostka zamčená nad horním okrajem = konec hry
if (cells.some(([, y]) => y < 0)) {
this.die();
return;
}
for (const [x, y] of cells) this.board[y * WIDTH + x] = this.active.type + 1;
this.active = null;
const cleared = this.clearLines();
if (cleared > 0) {
this.score += LINE_SCORES[cleared] * (this.level + 1);
this.lines += cleared;
}
this.callbacks.onLock(cleared);
if (this.pendingGarbage > 0) {
const overflow = this.addGarbage(this.pendingGarbage);
this.pendingGarbage = 0;
if (overflow) {
this.die();
return;
}
}
this.spawn();
}
private clearLines(): number {
const rows: number[][] = [];
for (let y = 0; y < HEIGHT; y++) rows.push(this.board.slice(y * WIDTH, (y + 1) * WIDTH));
const kept = rows.filter(row => row.some(c => c === 0));
const cleared = HEIGHT - kept.length;
if (cleared > 0) {
const empty = Array.from({ length: cleared }, () => new Array(WIDTH).fill(0));
this.board = [...empty, ...kept].flat();
}
return cleared;
}
/** Přidá odpadní řádky zespodu s jednou dírou; vrátí true, pokud deska přetekla. */
private addGarbage(lines: number): boolean {
const overflow = this.board.slice(0, lines * WIDTH).some(c => c !== 0);
const hole = Math.floor(Math.random() * WIDTH);
const garbageRows = Array.from({ length: lines }, () =>
Array.from({ length: WIDTH }, (_, x) => x === hole ? 0 : GARBAGE));
this.board = [...this.board.slice(lines * WIDTH), ...garbageRows.flat()].slice(-WIDTH * HEIGHT);
return overflow;
}
private die() {
if (!this.alive) return;
this.alive = false;
this.callbacks.onDeath();
}
}
+29
View File
@@ -0,0 +1,29 @@
import { StyleTheme, useSettings } from "../context/settings";
export type GameId = 'reaction' | 'snake' | 'tetris' | 'pong' | 'drawing';
/** Ikony miniher podle vizuálního stylu aplikace. */
const ICONS: Record<StyleTheme, Record<GameId, string>> = {
simple: { reaction: '⚡', snake: '🐍', tetris: '🧱', pong: '🏓', drawing: '🎨' },
// Dostihy: startovní vlajka, kůň místo hada, mrkve jako kostky, žokej, trofej
umamusume: { reaction: '🏁', snake: '🐎', tetris: '🥕', pong: '🏇', drawing: '🏆' },
// Duha, jednorožec, drahokamy, balónky a paleta
mlp: { reaction: '🌈', snake: '🦄', tetris: '💎', pong: '🎈', drawing: '🖌️' },
// Pekelná korespondence: plamen, rudá nit, lampion, slaměná panenka, svitek
hellgirl: { reaction: '🔥', snake: '🪢', tetris: '🏮', pong: '🪆', drawing: '📜' },
};
/** Styl z <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
function currentTheme(): StyleTheme {
const theme = document.documentElement.getAttribute('data-luncher-theme') as StyleTheme | null;
return theme && theme in ICONS ? theme : 'simple';
}
/** Ikona hry pro aktuální styl (mimo React, např. výpis do konzole). */
export const gameIcon = (game: GameId, theme: StyleTheme = currentTheme()) => ICONS[theme]?.[game] ?? ICONS.simple[game];
/** Ikona hry pro aktuální styl; stránky bez nastavení (statistiky) použijí atribut na <html>. */
export function useGameIcon(game: GameId) {
const settings = useSettings();
return gameIcon(game, settings?.styleTheme ?? currentTheme());
}
+127
View File
@@ -0,0 +1,127 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { openReactionDuel } from "../components/ReactionDuel";
import { openSnakeGame } from "../components/SnakeGame";
import { openTetrisGame } from "../components/TetrisGame";
import { openPongGame } from "../components/PongGame";
import { openDrawingGame } from "../components/DrawingGame";
import { GameId, gameIcon } from "../games/themeIcons";
type Minigame = {
id: GameId,
/** Kód, který se napíše po sérii kliknutí na logo. */
code: string,
name: string,
description: string,
open: () => void,
};
/** Skryté minihry dostupné přes logo. Novou hru stačí přidat sem. */
const MINIGAMES: Minigame[] = [
{
id: 'reaction',
code: 'reakce',
name: 'Reakční hra',
description: 'Kdo klikne nejrychleji? Hraje se s kolegy online, 3 kola.',
open: openReactionDuel,
},
{
id: 'snake',
code: 'snake',
name: 'Had',
description: 'Had pro více hráčů, ovládání šipkami nebo WSAD. Vyhrává poslední přeživší.',
open: openSnakeGame,
},
{
id: 'tetris',
code: 'tetris',
name: 'Tetris',
description: 'Tetris pro více hráčů — smazané řádky posílají soupeřům odpad.',
open: openTetrisGame,
},
{
id: 'pong',
code: 'pong',
name: 'Pong',
description: 'Pong pro dva, ovládání šipkami nebo W/S. Hraje se do 5 bodů.',
open: openPongGame,
},
{
id: 'drawing',
code: 'kresleni',
name: 'Kreslení',
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
open: openDrawingGame,
},
];
const HELP_CODE = 'help';
const SECRET_CLICKS = 3;
/** Max. pauza mezi kliknutími na logo, aby se počítala do série (ms). */
const SECRET_CLICK_GAP_MS = 400;
/** Jak dlouho po sérii kliknutí lze napsat kód (ms). */
const SECRET_ARM_MS = 8000;
function printHelp() {
console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold');
console.log('Klikněte 3× na logo a napište kód hry:');
console.table(MINIGAMES.map(g => ({ kód: g.code, hra: `${gameIcon(g.id)} ${g.name}`, popis: g.description })));
}
/**
* Skrytý vstup do miniher: 3× rychle kliknout na logo a pak napsat kód hry,
* případně "help" pro výpis her do konzole prohlížeče.
* Jedno či dvě kliknutí se chovají jako běžný odkaz (jen s malým zpožděním).
*/
export function useMinigameSecret(href: string) {
const [armed, setArmed] = useState(false);
const clicks = useRef(0);
const timer = useRef<number | undefined>(undefined);
const onLogoClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
// Otevření v novém tabu apod. necháme prohlížeči
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
window.clearTimeout(timer.current);
clicks.current++;
if (clicks.current >= SECRET_CLICKS) {
clicks.current = 0;
setArmed(true);
return;
}
timer.current = window.setTimeout(() => {
clicks.current = 0;
window.location.assign(href);
}, SECRET_CLICK_GAP_MS);
}, [href]);
useEffect(() => {
if (!armed) return;
const maxLength = Math.max(HELP_CODE.length, ...MINIGAMES.map(g => g.code.length));
let typed = '';
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"]') || e.key.length !== 1) return;
typed = (typed + e.key.toLowerCase()).slice(-maxLength);
if (typed.endsWith(HELP_CODE)) {
setArmed(false);
printHelp();
return;
}
const game = MINIGAMES.find(g => typed.endsWith(g.code));
if (game) {
setArmed(false);
game.open();
}
};
const disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
window.addEventListener('keydown', onKey);
return () => {
window.clearTimeout(disarm);
window.removeEventListener('keydown', onKey);
};
}, [armed]);
useEffect(() => () => window.clearTimeout(timer.current), []);
return { armed, onLogoClick };
}