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
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:
28 files changed
+5050
-2
No files matched your search
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user