feat: dotykové ovládání miniher na mobilu a tabletu
CI / Generate TypeScript types (push) Successful in 13s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 33s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 1m54s
CI / Build and push Docker image (push) Successful in 46s
CI / Notify (push) Successful in 2s

This commit is contained in:
batmanisko committed 2026-10-20 14:43:49 +02:00
1 parent a8b67fd76e
commit d2125a050b
18 files changed
+503 -63

No files matched your search

+21
View File
@@ -142,4 +142,25 @@
.draw-players {
columns: 2;
}
// Na úzkém displeji (telefon na výšku) jde chat pod plátno, jinak by se plátno smrsklo na proužek
@media (max-width: 767.98px) {
.draw-topbar {
flex-wrap: wrap;
gap: 4px 16px;
}
.draw-layout {
flex-direction: column;
}
.draw-side {
flex: 0 0 auto;
}
.draw-chat {
min-height: 120px;
max-height: 200px;
}
}
}
+1 -1
View File
@@ -414,7 +414,7 @@ export default function DrawingGame() {
{icon} Kreslení běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="drawing-game-modal">
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="drawing-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Kreslení</h2></Modal.Title>
</Modal.Header>
+2
View File
@@ -155,6 +155,8 @@ export default function Header({ choices, dayIndex }: Props) {
return <Navbar variant='dark' expand="lg">
<Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand>
<input ref={minigameSecret.inputRef} className="minigame-secret-input" tabIndex={-1} aria-hidden="true"
autoComplete="off" autoCorrect="off" autoCapitalize="none" spellCheck={false} />
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="nav">
+5
View File
@@ -21,6 +21,11 @@
position: relative;
max-width: 800px;
margin: 0 auto;
// Tah prstem po hřišti hýbe pálkou, ne stránkou
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.pong-board {
+9 -4
View File
@@ -4,6 +4,7 @@ import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import { useTouchDevice } from "../hooks/useTouchDevice";
import "./PongGame.scss";
// Typy musí odpovídat server/src/pongGame.ts (stav chodí přes websocket, ne přes OpenAPI)
@@ -84,6 +85,7 @@ export default function PongGame() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<PongPhase>('idle');
const icon = useGameIcon('pong');
const touch = useTouchDevice();
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);
@@ -169,7 +171,7 @@ export default function PongGame() {
};
}, [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)
// Ovládání myší či prstem: 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;
@@ -187,9 +189,12 @@ export default function PongGame() {
sent = latest;
send('pong:aim', { y: latest });
}, AIM_SEND_MS);
// Prst nemá "hover" — pálka skočí už na dotyk, ne až na první pohyb
window.addEventListener('pointerdown', onMove);
window.addEventListener('pointermove', onMove);
return () => {
window.clearInterval(id);
window.removeEventListener('pointerdown', onMove);
window.removeEventListener('pointermove', onMove);
};
}, [open, inGame, isPlayer, send]);
@@ -219,7 +224,7 @@ export default function PongGame() {
{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>
<div className="pong-status">{isPlayer ? (touch ? 'Táhněte prstem po hřišti' : 'Myš, nebo ↑↓ / W S') : 'Sledujete zápas'}</div>
</>
);
@@ -228,7 +233,7 @@ export default function PongGame() {
case 'idle':
return (
<p>
Klasický Pong pro dva. Ovládání myší (pálka sleduje kurzor) nebo šipkami / W S, hraje se
Klasický Pong pro dva. Ovládání myší (pálka sleduje kurzor), prstem po hřišti 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>
);
@@ -295,7 +300,7 @@ export default function PongGame() {
{icon} Pong běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="pong-game-modal">
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="pong-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Pong</h2></Modal.Title>
</Modal.Header>
+20
View File
@@ -101,6 +101,26 @@
}
}
// Bez zoomu dvojitým ťuknutím, jinak by iOS sérii ťuknutí na logo spolkl
.navbar-brand {
touch-action: manipulation;
}
// Skryté pole pro kód minihry — musí jít zaměřit (kvůli softwarové klávesnici), proto ne display: none.
// Písmo aspoň 16px, jinak iOS při zaměření přiblíží stránku.
.minigame-secret-input {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
padding: 0;
border: 0;
font-size: 16px;
opacity: 0;
pointer-events: none;
}
// Nenápadné potvrzení, že logo přijalo sérii kliknutí a čeká na tajné slovo
.navbar-brand.duel-armed {
display: inline-block;
+4 -2
View File
@@ -4,6 +4,7 @@ import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import { useTouchDevice } from "../hooks/useTouchDevice";
import "./ReactionDuel.scss";
// Typy musí odpovídat server/src/reactionDuel.ts (stav chodí přes websocket, ne přes OpenAPI)
@@ -46,6 +47,7 @@ export default function ReactionDuel() {
const [myClick, setMyClick] = useState<{ round: number, early: boolean } | null>(null);
const prevPhase = useRef<DuelPhase>('idle');
const icon = useGameIcon('reaction');
const touch = useTouchDevice();
const send = useCallback((event: string) => {
setError(null);
@@ -130,7 +132,7 @@ export default function ReactionDuel() {
<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">{touch ? 'Ťukněte, až zezelená.' : 'Klikněte nebo zmáčkněte mezerník, až zezelená.'}</div>
: <div className="duel-hint">Sledujete hru.</div>}
</div>
);
@@ -247,7 +249,7 @@ export default function ReactionDuel() {
{icon} Reakční hra běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="lg" className="reaction-duel-modal">
<Modal show={open} onHide={() => setOpen(false)} size="lg" fullscreen="sm-down" className="reaction-duel-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Reakční hra</h2></Modal.Title>
</Modal.Header>
+20
View File
@@ -16,6 +16,26 @@
height: auto;
border-radius: 8px;
image-rendering: pixelated;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
// Kříž šipek pro dotykové ovládání
.snake-touch-pad {
display: grid;
grid-template-areas: ". up ." "left . right" ". down .";
grid-template-columns: repeat(3, calc(var(--touch-btn-size) + 8px));
grid-template-rows: repeat(3, var(--touch-btn-size));
gap: 4px;
justify-content: center;
margin: 8px 0 16px;
.up { grid-area: up; }
.left { grid-area: left; }
.right { grid-area: right; }
.down { grid-area: down; }
}
.snake-overlay {
+56 -7
View File
@@ -1,9 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Modal, Table } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCaretDown, faCaretLeft, faCaretRight, faCaretUp } from "@fortawesome/free-solid-svg-icons";
import { socket } from "../context/socket";
import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import { useTouchDevice } from "../hooks/useTouchDevice";
import TouchButton from "./TouchButton";
import "./SnakeGame.scss";
// Typy musí odpovídat server/src/snakeGame.ts (stav chodí přes websocket, ne přes OpenAPI)
@@ -38,6 +42,9 @@ const KEY_DIRECTIONS: Record<string, Direction> = {
KeyW: 'up', KeyS: 'down', KeyA: 'left', KeyD: 'right',
};
/** Délka tahu prstem po ploše (px), od které se bere jako zatočení. */
const SWIPE_PX = 24;
/** Vykreslí hrací plochu do canvasu. */
function draw(canvas: HTMLCanvasElement, state: SnakeState, me?: string) {
const ctx = canvas.getContext('2d');
@@ -114,6 +121,9 @@ export default function SnakeGame() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<SnakePhase>('idle');
const icon = useGameIcon('snake');
const touch = useTouchDevice();
/** Rozpracovaný tah prstem — bod, od kterého se měří další zatočení, a poslední směr v tomto tahu. */
const swipe = useRef<{ pointerId: number, x: number, y: number, last?: Direction } | null>(null);
const send = useCallback((event: string, extra?: object) => {
setError(null);
@@ -153,6 +163,11 @@ export default function SnakeGame() {
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'countdown' || phase === 'playing';
const controllable = open && inGame && isPlayer;
const turn = useCallback((dir: Direction) => {
if (controllable) send('snake:turn', { dir });
}, [controllable, send]);
// Hráčům se okno otevře samo, jakmile hra začne
useEffect(() => {
@@ -164,16 +179,39 @@ export default function SnakeGame() {
// Ovládání šipkami a WSAD
useEffect(() => {
if (!open || !inGame || !isPlayer) return;
if (!controllable) return;
const onKey = (e: KeyboardEvent) => {
const dir = KEY_DIRECTIONS[e.code];
if (!dir) return;
e.preventDefault();
if (!e.repeat) send('snake:turn', { dir });
if (!e.repeat) turn(dir);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, inGame, isPlayer, send]);
}, [controllable, turn]);
// Ovládání tahem prstu po ploše; v jednom tahu jde zatočit i víckrát (cik-cak)
const onBoardPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (e.pointerType === 'mouse' || !controllable) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
swipe.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY };
};
const onBoardPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
const s = swipe.current;
if (!s || s.pointerId !== e.pointerId) return;
const dx = e.clientX - s.x;
const dy = e.clientY - s.y;
if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_PX) return;
const dir: Direction = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'down' : 'up');
if (dir !== s.last) turn(dir);
swipe.current = { ...s, x: e.clientX, y: e.clientY, last: dir };
};
const onBoardPointerEnd = () => {
swipe.current = null;
};
useEffect(() => {
if (canvasRef.current && state && (inGame || phase === 'finished')) {
@@ -190,7 +228,9 @@ export default function SnakeGame() {
const renderBoard = () => state && (
<div className="snake-board-wrap">
<canvas ref={canvasRef} className="snake-board" width={state.width * 24} height={state.height * 24} />
<canvas ref={canvasRef} className="snake-board" width={state.width * 24} height={state.height * 24}
onPointerDown={onBoardPointerDown} onPointerMove={onBoardPointerMove}
onPointerUp={onBoardPointerEnd} onPointerCancel={onBoardPointerEnd} />
{phase === 'countdown' && <div className="snake-overlay">{state.countdown}</div>}
</div>
);
@@ -221,7 +261,8 @@ export default function SnakeGame() {
case 'idle':
return (
<p>
Klasický had pro více hráčů. Ovládání šipkami nebo WSAD. Jezte jídlo a vyhněte se zdem,
Klasický had pro více hráčů. Ovládání šipkami nebo WSAD, na dotykovém displeji tahem prstu
po ploše nebo tlačítky. 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>
@@ -246,8 +287,16 @@ export default function SnakeGame() {
<>
{renderBoard()}
<div className="snake-status">
{isPlayer ? 'Šipky / WSAD' : 'Sledujete hru'} · zbývá {secondsLeft} s
{isPlayer ? (touch ? 'Táhněte prstem po ploše' : 'Šipky / WSAD') : 'Sledujete hru'} · zbývá {secondsLeft} s
</div>
{touch && isPlayer && (
<div className="snake-touch-pad">
<TouchButton label="Nahoru" className="up" onPress={() => turn('up')}><FontAwesomeIcon icon={faCaretUp} /></TouchButton>
<TouchButton label="Doleva" className="left" onPress={() => turn('left')}><FontAwesomeIcon icon={faCaretLeft} /></TouchButton>
<TouchButton label="Doprava" className="right" onPress={() => turn('right')}><FontAwesomeIcon icon={faCaretRight} /></TouchButton>
<TouchButton label="Dolů" className="down" onPress={() => turn('down')}><FontAwesomeIcon icon={faCaretDown} /></TouchButton>
</div>
)}
{renderScores()}
</>
);
@@ -295,7 +344,7 @@ export default function SnakeGame() {
{icon} Had běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="snake-game-modal">
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="snake-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Had</h2></Modal.Title>
</Modal.Header>
+58
View File
@@ -12,9 +12,17 @@
align-items: flex-start;
}
.tetris-own-col {
display: flex;
flex-direction: column;
gap: 12px;
}
.tetris-own {
display: flex;
gap: 16px;
// Ovládání pod deskou může být na tabletu širší než deska s postranním panelem
align-self: center;
}
.tetris-board-wrap {
@@ -24,6 +32,22 @@
.tetris-board {
display: block;
border-radius: 6px;
// Gesta prstem nesmí posouvat ani přibližovat stránku
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.tetris-touch-pad {
display: flex;
justify-content: space-between;
gap: 12px;
}
.tetris-touch-group {
display: flex;
gap: calc(var(--touch-btn-size) * 0.12);
}
.tetris-overlay {
@@ -116,6 +140,40 @@
.tetris-players {
columns: 2;
}
// Na dotykovém displeji se deska zmenší, aby se i s ovládáním vešla na výšku telefonu
@media (any-pointer: coarse) {
.tetris-own {
gap: 12px;
}
.tetris-board {
width: auto;
// Výška okna bez hlavičky, patičky, odstupů a řady tlačítek
height: clamp(280px, calc(100dvh - 278px - var(--touch-btn-size)), #{20 * 26px});
}
.tetris-side {
min-width: 90px;
}
}
@media (max-width: 575.98px) {
.modal-body {
padding: 12px;
}
.tetris-layout {
gap: 12px;
}
}
}
// Bootstrap dává oknu mezi 576 a 992 px (tablet na výšku) jen 500 px a větší dotyková tlačítka by se nevešla
@media (min-width: 576px) and (max-width: 991.98px) {
.tetris-game-modal .modal-dialog {
--bs-modal-width: 640px;
}
}
.tetris-game-modal .tetris-settings {
+160 -40
View File
@@ -1,10 +1,14 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Form, Modal, Table } from "react-bootstrap";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faAnglesDown, faCaretDown, faCaretLeft, faCaretRight, faRotateLeft, faRotateRight } from "@fortawesome/free-solid-svg-icons";
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 { useTouchDevice } from "../hooks/useTouchDevice";
import TouchButton from "./TouchButton";
import "./TetrisGame.scss";
// Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI)
@@ -60,7 +64,9 @@ const SNAPSHOT_MS = 250;
*/
const HEARTBEAT_MS = 2000;
const KEYS: Record<string, 'left' | 'right' | 'down' | 'rotate' | 'rotateBack' | 'drop'> = {
type Action = 'left' | 'right' | 'down' | 'rotate' | 'rotateBack' | 'drop';
const KEYS: Record<string, Action> = {
ArrowLeft: 'left', KeyA: 'left',
ArrowRight: 'right', KeyD: 'right',
ArrowDown: 'down', KeyS: 'down',
@@ -68,6 +74,36 @@ const KEYS: Record<string, 'left' | 'right' | 'down' | 'rotate' | 'rotateBack' |
KeyZ: 'rotateBack', KeyY: 'rotateBack',
Space: 'drop',
};
/** Akce, které se opakují při podržení klávesy. */
const REPEATABLE = new Set<Action>(['left', 'right', 'down']);
// Dotyková gesta na desce: ťuknutí otočí, tažení posouvá o políčka, švihnutí dolů shodí
/** Posun prstu (px), do kterého se dotyk ještě bere jako ťuknutí. */
const TAP_SLOP_PX = 10;
/** Delší dotyk bez pohybu už není ťuknutí. */
const TAP_MAX_MS = 300;
/** Nejmenší krok tažení o jedno políčko (px) — na malé desce by jinak byl příliš citlivý. */
const MIN_STEP_PX = 14;
/** Švihnutí = pohyb dolů aspoň touto rychlostí (px/ms) za posledních FLICK_WINDOW_MS. */
const FLICK_SPEED = 0.7;
const FLICK_MIN_PX = 30;
const FLICK_WINDOW_MS = 100;
/** Rozpracované dotykové gesto na desce. */
type Gesture = {
pointerId: number,
/** Kostka, se kterou gesto začalo — když mezitím dopadne, zbytek gesta se ignoruje. */
pieceNo: number,
startX: number,
startY: number,
startTime: number,
/** Bod, od kterého se měří další krok o políčko. */
anchorX: number,
anchorY: number,
moved: boolean,
/** Poslední polohy prstu pro rychlost švihnutí. */
trail: { y: number, t: number }[],
};
function drawCell(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, value: number) {
ctx.fillStyle = COLORS[value];
@@ -141,6 +177,8 @@ export default function TetrisGame() {
const deadline = useRef<number | undefined>(undefined);
const [now, setNow] = useState(() => Date.now());
const icon = useGameIcon('tetris');
const touch = useTouchDevice();
const gesture = useRef<Gesture | null>(null);
const send = useCallback((event: string, extra?: object) => {
setError(null);
@@ -199,6 +237,7 @@ export default function TetrisGame() {
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
const phase = state?.phase ?? 'idle';
const inGame = phase === 'countdown' || phase === 'playing';
const controllable = open && (practice || (phase === 'playing' && isPlayer));
// Hráčům se okno otevře samo, jakmile hra začne
useEffect(() => {
@@ -270,27 +309,38 @@ export default function TetrisGame() {
return () => window.clearInterval(id);
}, [practice, phase, isPlayer, sendSnapshot]);
// Ovládání
/** Herní akce z klávesnice i dotykového ovládání. */
const control = useCallback((action: Action) => {
const s = sessionRef.current;
if (!controllable || !s?.alive) return;
switch (action) {
case 'left': s.move(-1); break;
case 'right': s.move(1); break;
case 'down': s.softDrop(); break;
case 'rotate': s.rotate(1); break;
case 'rotateBack': s.rotate(-1); break;
case 'drop': s.hardDrop(); break;
}
}, [controllable]);
// Deska mohla zmizet s prstem na ní (konec hry, zavření okna) — rozpracované gesto se zahodí
useEffect(() => {
if (!open || !(practice || (phase === 'playing' && isPlayer))) return;
if (!controllable) gesture.current = null;
}, [controllable]);
// Ovládání klávesnicí
useEffect(() => {
if (!controllable) return;
const onKey = (e: KeyboardEvent) => {
const action = KEYS[e.code];
const s = sessionRef.current;
if (!action || !s) return;
if (!action || !sessionRef.current) 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;
}
if (e.repeat && !REPEATABLE.has(action)) return;
control(action);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, practice, phase, isPlayer]);
}, [controllable, control]);
// Po skončení hry se session zahodí, ať se příště začíná načisto
useEffect(() => {
@@ -315,6 +365,56 @@ export default function TetrisGame() {
setPractice(false);
}, []);
// Dotyková gesta na desce (myš se ignoruje, aby kliknutí do desky na počítači neotáčelo kostkou)
const onBoardPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
const s = sessionRef.current;
if (e.pointerType === 'mouse' || !controllable || !s?.alive) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
gesture.current = {
pointerId: e.pointerId, pieceNo: s.pieceNo,
startX: e.clientX, startY: e.clientY, startTime: e.timeStamp,
anchorX: e.clientX, anchorY: e.clientY, moved: false,
trail: [{ y: e.clientY, t: e.timeStamp }],
};
};
const onBoardPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
const g = gesture.current;
const s = sessionRef.current;
if (!g || g.pointerId !== e.pointerId || !s || s.pieceNo !== g.pieceNo) return;
if (Math.abs(e.clientX - g.startX) > TAP_SLOP_PX || Math.abs(e.clientY - g.startY) > TAP_SLOP_PX) g.moved = true;
const step = Math.max(MIN_STEP_PX, e.currentTarget.getBoundingClientRect().width / WIDTH);
for (; e.clientX - g.anchorX >= step; g.anchorX += step) control('right');
for (; g.anchorX - e.clientX >= step; g.anchorX -= step) control('left');
// Tažení dolů kostku nezamkne (to udělá gravitace nebo švihnutí), aby tah nepokračoval na další kostce
for (; e.clientY - g.anchorY >= step; g.anchorY += step) {
if (!s.grounded) control('down');
}
g.anchorY = Math.min(g.anchorY, e.clientY);
g.trail.push({ y: e.clientY, t: e.timeStamp });
while (e.timeStamp - g.trail[0].t > FLICK_WINDOW_MS) g.trail.shift();
};
const onBoardPointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
const g = gesture.current;
if (!g || g.pointerId !== e.pointerId) return;
gesture.current = null;
if (sessionRef.current?.pieceNo !== g.pieceNo) return;
if (!g.moved) {
if (e.timeStamp - g.startTime <= TAP_MAX_MS) control('rotate');
return;
}
const from = g.trail.find(p => e.timeStamp - p.t <= FLICK_WINDOW_MS);
if (!from || e.timeStamp <= from.t) return;
const dy = e.clientY - from.y;
if (dy >= FLICK_MIN_PX && dy / (e.timeStamp - from.t) >= FLICK_SPEED) control('drop');
};
const onBoardPointerCancel = () => {
gesture.current = null;
};
if (!login) return null;
const session = sessionRef.current;
@@ -368,32 +468,52 @@ export default function TetrisGame() {
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">
{remaining !== undefined && (
<>
<div className="tetris-label">Zbývá</div>
<div className="tetris-value">{formatTime(remaining)}</div>
</>
)}
<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 className="tetris-own-col">
<div className="tetris-own">
<div className="tetris-board-wrap">
<canvas ref={boardRef} className="tetris-board" width={WIDTH * CELL} height={HEIGHT * CELL}
onPointerDown={onBoardPointerDown} onPointerMove={onBoardPointerMove}
onPointerUp={onBoardPointerUp} onPointerCancel={onBoardPointerCancel} />
{!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">
{remaining !== undefined && (
<>
<div className="tetris-label">Zbývá</div>
<div className="tetris-value">{formatTime(remaining)}</div>
</>
)}
<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">
{touch
? <>ťuknutí otočí<br />tažení posouvá<br />švihnutí dolů shodí</>
: <>←→ / AD posun<br />↑ / W otočit<br />↓ / S dolů<br />mezerník shodit</>}
</div>
</div>
</div>
{touch && (
<div className="tetris-touch-pad">
<div className="tetris-touch-group">
<TouchButton label="Doleva" repeat onPress={() => control('left')}><FontAwesomeIcon icon={faCaretLeft} /></TouchButton>
<TouchButton label="Dolů" repeat onPress={() => control('down')}><FontAwesomeIcon icon={faCaretDown} /></TouchButton>
<TouchButton label="Doprava" repeat onPress={() => control('right')}><FontAwesomeIcon icon={faCaretRight} /></TouchButton>
</div>
<div className="tetris-touch-group">
<TouchButton label="Otočit po směru hodinových ručiček" onPress={() => control('rotateBack')}><FontAwesomeIcon icon={faRotateRight} /></TouchButton>
<TouchButton label="Otočit proti směru hodinových ručiček" onPress={() => control('rotate')}><FontAwesomeIcon icon={faRotateLeft} /></TouchButton>
<TouchButton label="Shodit" onPress={() => control('drop')}><FontAwesomeIcon icon={faAnglesDown} /></TouchButton>
</div>
</div>
)}
</div>
)}
{others.length > 0 && (
@@ -526,7 +646,7 @@ export default function TetrisGame() {
{icon} Tetris běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="tetris-game-modal">
<Modal show={open} onHide={() => setOpen(false)} size="xl" fullscreen="sm-down" className="tetris-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Tetris</h2></Modal.Title>
</Modal.Header>
+37
View File
@@ -0,0 +1,37 @@
// Velikost dotykových tlačítek — podle ní se řídí i rozložení ovládání v jednotlivých hrách
:root {
--touch-btn-size: 52px;
}
// Tablet (na rozdíl od telefonu na šířku je vysoký i široký) — na velkém displeji jsou malá tlačítka špatně trefitelná
@media (any-pointer: coarse) and (min-width: 700px) and (min-height: 600px) {
:root {
--touch-btn-size: 76px;
}
}
// Dotyková tlačítka miniher — bez zoomu dvojitým ťuknutím, výběru textu a kontextové nabídky při podržení
.touch-btn {
display: flex;
align-items: center;
justify-content: center;
min-width: var(--touch-btn-size);
height: var(--touch-btn-size);
padding: 0 10px;
border: 1px solid var(--luncher-border, rgba(127, 127, 127, 0.4));
border-radius: calc(var(--touch-btn-size) * 0.23);
background: var(--bs-tertiary-bg);
color: var(--luncher-text, inherit);
font-size: calc(var(--touch-btn-size) * 0.45);
line-height: 1;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
&:active {
background: var(--luncher-primary, #198754);
color: #fff;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { ReactNode, useEffect, useRef } from "react";
import "./TouchButton.scss";
/** Prodleva před opakováním a interval opakování při podržení tlačítka (ms), podobně jako u klávesnice. */
const REPEAT_DELAY_MS = 170;
const REPEAT_MS = 50;
type Props = {
label: string,
onPress: () => void,
/** Při podržení se akce opakuje (posun, pád). */
repeat?: boolean,
className?: string,
children: ReactNode,
};
/**
* Tlačítko dotykového ovládání miniher. Reaguje hned na dotyk (ne až na click) a je mimo pořadí
* fokusu, aby mezerník nebo šipky z klávesnice nespouštěly tlačítko místo hry.
*/
export default function TouchButton({ label, onPress, repeat, className, children }: Props) {
const timer = useRef<number | undefined>(undefined);
const onPressRef = useRef(onPress);
onPressRef.current = onPress;
const stop = () => {
window.clearTimeout(timer.current);
timer.current = undefined;
};
useEffect(() => stop, []);
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
stop();
onPressRef.current();
if (!repeat) return;
const tick = () => {
onPressRef.current();
timer.current = window.setTimeout(tick, REPEAT_MS);
};
timer.current = window.setTimeout(tick, REPEAT_DELAY_MS);
};
return (
<button type="button" tabIndex={-1} aria-label={label} className={`touch-btn ${className ?? ''}`}
onPointerDown={onPointerDown} onPointerUp={stop} onPointerCancel={stop} onLostPointerCapture={stop}
onContextMenu={e => e.preventDefault()}>
{children}
</button>
);
}
+2 -1
View File
@@ -4,7 +4,8 @@ import socketio from "socket.io-client";
let socketUrl;
let socketPath;
if (process.env.NODE_ENV === 'development') {
socketUrl = `http://localhost:3001`;
// Stejný stroj jako stránka, aby šlo vývojovou verzi zkoušet i z telefonu či tabletu v síti
socketUrl = `http://${globalThis.location.hostname}:3001`;
socketPath = undefined;
} else {
socketUrl = `${globalThis.location.host}`;
+8
View File
@@ -81,6 +81,8 @@ export class TetrisSession {
score = 0;
lines = 0;
alive = true;
/** Pořadové číslo padající kostky — dotykové gesto podle něj pozná, že jeho kostka mezitím dopadla. */
pieceNo = 0;
private random: () => number;
private bag: number[] = [];
private pendingGarbage = 0;
@@ -117,6 +119,11 @@ export class TetrisSession {
if (this.alive) this.tryMove(dx, 0);
}
/** Zda kostka leží na dně či na jiných kostkách (další krok dolů by ji zamkl). */
get grounded() {
return !!this.active && !this.fits({ ...this.active, y: this.active.y + 1 });
}
softDrop() {
if (!this.alive) return;
if (this.tryMove(0, 1)) this.score += 1;
@@ -182,6 +189,7 @@ export class TetrisSession {
return;
}
this.active = piece;
this.pieceNo++;
}
private fits(p: Piece) {
+30 -7
View File
@@ -67,15 +67,20 @@ function printHelp() {
console.table(MINIGAMES.map(g => ({ kód: g.code, hra: `${gameIcon(g.id)} ${g.name}`, popis: g.description })));
}
/** Malá písmena bez diakritiky — česká klávesnice na mobilu může "kresleni" opravit na "Kreslení". */
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.
* 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.
*/
export function useMinigameSecret(href: string) {
const [armed, setArmed] = useState(false);
const clicks = useRef(0);
const timer = useRef<number | undefined>(undefined);
const inputRef = useRef<HTMLInputElement>(null);
const onLogoClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
// Otevření v novém tabu apod. necháme prohlížeči
@@ -85,6 +90,12 @@ 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 });
}
setArmed(true);
return;
}
@@ -96,32 +107,44 @@ export function useMinigameSecret(href: string) {
useEffect(() => {
if (!armed) return;
const input = inputRef.current;
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)) {
const check = (text: string) => {
if (text.endsWith(HELP_CODE)) {
setArmed(false);
printHelp();
return;
}
const game = MINIGAMES.find(g => typed.endsWith(g.code));
const game = MINIGAMES.find(g => text.endsWith(g.code));
if (game) {
setArmed(false);
game.open();
}
};
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 + normalizeCode(e.key)).slice(-maxLength);
check(typed);
};
// 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);
window.addEventListener('keydown', onKey);
input?.addEventListener('input', onInput);
return () => {
window.clearTimeout(disarm);
window.removeEventListener('keydown', onKey);
input?.removeEventListener('input', onInput);
if (input) {
input.value = '';
input.blur();
}
};
}, [armed]);
useEffect(() => () => window.clearTimeout(timer.current), []);
return { armed, onLogoClick };
return { armed, onLogoClick, inputRef };
}
+16
View File
@@ -0,0 +1,16 @@
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;
}