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

+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 };
}