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
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:
42 files changed
+136
-8224
No files matched your search
@@ -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), []);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user