feat: vytržení miniher do samostatného projektu
CI / Server unit tests (push) Successful in 26s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 2m24s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s

This commit is contained in:
mates committed 2026-10-22 12:42:49 +02:00
1 parent 8a7bbef729
commit 35cf2ca9d7
42 files changed
+136 -8224

No files matched your search

+47 -56
View File
@@ -1,11 +1,7 @@
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 { openTankGame } from "../components/TankGame";
import { GameId, gameIcon } from "../games/themeIcons";
import { useAppConfig } from "./useGroupLimits";
type Minigame = {
id: GameId,
@@ -16,36 +12,11 @@ type Minigame = {
open: () => void,
};
/** Skryté minihry dostupné přes logo. Novou hru stačí přidat sem. */
/**
* Skryté minihry, které zůstaly v Luncheru (ostatní jsou v samostatné aplikaci Lobby).
* 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',
@@ -53,25 +24,23 @@ const MINIGAMES: Minigame[] = [
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
open: openDrawingGame,
},
{
id: 'tanks',
code: 'tanky',
name: 'Tanky',
description: 'Tanková bitva pro více hráčů (i s boty), střely se odráží od zdí. Na čas nebo na poslední přeživší.',
open: openTankGame,
},
];
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). */
/** Jak dlouho po sérii kliknutí (případně po návratu z Lobby do této záložky) lze napsat kód (ms). */
const SECRET_ARM_MS = 8000;
function printHelp() {
function printHelp(lobbyUrl?: string) {
console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold');
console.log('Klikněte 3× na logo a napište kód hry:');
if (lobbyUrl) {
console.log(`Klikněte 3× na logo — otevře se Lobby s dalšími minihrami (${lobbyUrl}).`);
console.log('Po návratu do Luncheru lze napsat kód hry:');
} else {
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 })));
}
@@ -79,12 +48,14 @@ function printHelp() {
const normalizeCode = (text: string) => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
/**
* 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.
* Skrytý vstup do miniher: 3× rychle kliknout na logo. Je-li nastavena adresa Lobby, otevře se v nové záložce.
* Poté lze (po návratu do Luncheru) napsat kód hry, která zůstala v Luncheru, případně "help" pro výpis do konzole prohlížeče.
* Jedno či dvě kliknutí se chovají jako běžný odkaz (jen s malým zpožděním).
* Na dotykových zařízeních se kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici.
* Bez Lobby se na dotykových zařízeních kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici.
* Po otevření Lobby klávesnici po návratu do záložky otevřít nelze (chce gesto uživatele), na mobilu tak kód zadat nepůjde.
*/
export function useMinigameSecret(href: string) {
const { lobbyUrl } = useAppConfig();
const [armed, setArmed] = useState(false);
const clicks = useRef(0);
const timer = useRef<number | undefined>(undefined);
@@ -98,11 +69,16 @@ export function useMinigameSecret(href: string) {
clicks.current++;
if (clicks.current >= SECRET_CLICKS) {
clicks.current = 0;
// Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře
const input = inputRef.current;
if (input) {
input.value = '';
input.focus({ preventScroll: true });
if (lobbyUrl) {
// Otevření musí proběhnout přímo v obsluze kliknutí, jinak ho prohlížeč zablokuje jako vyskakovací okno
window.open(lobbyUrl, '_blank', 'noopener');
} else {
// Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře
const input = inputRef.current;
if (input) {
input.value = '';
input.focus({ preventScroll: true });
}
}
setArmed(true);
return;
@@ -111,7 +87,7 @@ export function useMinigameSecret(href: string) {
clicks.current = 0;
window.location.assign(href);
}, SECRET_CLICK_GAP_MS);
}, [href]);
}, [href, lobbyUrl]);
useEffect(() => {
if (!armed) return;
@@ -121,7 +97,7 @@ export function useMinigameSecret(href: string) {
const check = (text: string) => {
if (text.endsWith(HELP_CODE)) {
setArmed(false);
printHelp();
printHelp(lobbyUrl);
return;
}
const game = MINIGAMES.find(g => text.endsWith(g.code));
@@ -138,19 +114,34 @@ export function useMinigameSecret(href: string) {
};
// Softwarová klávesnice (Android) posílá v keydown jen "Unidentified", proto se čte celý text pole
const onInput = () => check(normalizeCode(input?.value ?? ''));
const disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
// Limit běží, jen když je záložka vidět — po odchodu do Lobby se zastaví a po návratu začne znovu
let disarm: number | undefined;
const startDisarm = () => {
window.clearTimeout(disarm);
disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
};
const onVisibility = () => {
if (document.visibilityState === 'visible') {
startDisarm();
} else {
window.clearTimeout(disarm);
}
};
startDisarm();
window.addEventListener('keydown', onKey);
input?.addEventListener('input', onInput);
document.addEventListener('visibilitychange', onVisibility);
return () => {
window.clearTimeout(disarm);
window.removeEventListener('keydown', onKey);
input?.removeEventListener('input', onInput);
document.removeEventListener('visibilitychange', onVisibility);
if (input) {
input.value = '';
input.blur();
}
};
}, [armed]);
}, [armed, lobbyUrl]);
useEffect(() => () => window.clearTimeout(timer.current), []);
-16
View File
@@ -1,16 +0,0 @@
import { useEffect, useState } from "react";
/** Libovolné dotykové ovládání — iPad se hlásí jako Mac, takže se nedá spoléhat na user agent. */
const QUERY = '(any-pointer: coarse)';
/** Zda má zařízení dotykový displej (podle toho minihry ukazují dotyková tlačítka a nápovědu). */
export function useTouchDevice() {
const [touch, setTouch] = useState(() => window.matchMedia(QUERY).matches);
useEffect(() => {
const media = window.matchMedia(QUERY);
const onChange = () => setTouch(media.matches);
media.addEventListener('change', onChange);
return () => media.removeEventListener('change', onChange);
}, []);
return touch;
}