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
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:
18 files changed
+503
-63
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user