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
+479
-39
No files matched your search
@@ -142,4 +142,25 @@
|
|||||||
.draw-players {
|
.draw-players {
|
||||||
columns: 2;
|
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
|
{icon} Kreslení běží — zpět do hry
|
||||||
</button>
|
</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.Header closeButton>
|
||||||
<Modal.Title><h2>{icon} Kreslení</h2></Modal.Title>
|
<Modal.Title><h2>{icon} Kreslení</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
|
|
||||||
return <Navbar variant='dark' expand="lg">
|
return <Navbar variant='dark' expand="lg">
|
||||||
<Navbar.Brand href="/" onClick={minigameSecret.onLogoClick} className={minigameSecret.armed ? "duel-armed" : undefined}>Luncher</Navbar.Brand>
|
<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.Toggle aria-controls="basic-navbar-nav" />
|
||||||
<Navbar.Collapse id="basic-navbar-nav">
|
<Navbar.Collapse id="basic-navbar-nav">
|
||||||
<Nav className="nav">
|
<Nav className="nav">
|
||||||
|
|||||||
@@ -21,6 +21,11 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
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 {
|
.pong-board {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { socket } from "../context/socket";
|
|||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import { getToken } from "../Utils";
|
import { getToken } from "../Utils";
|
||||||
import { useGameIcon } from "../games/themeIcons";
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import { useTouchDevice } from "../hooks/useTouchDevice";
|
||||||
import "./PongGame.scss";
|
import "./PongGame.scss";
|
||||||
|
|
||||||
// Typy musí odpovídat server/src/pongGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
// 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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const prevPhase = useRef<PongPhase>('idle');
|
const prevPhase = useRef<PongPhase>('idle');
|
||||||
const icon = useGameIcon('pong');
|
const icon = useGameIcon('pong');
|
||||||
|
const touch = useTouchDevice();
|
||||||
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
|
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. */
|
/** 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);
|
const fieldHeight = useRef(500);
|
||||||
@@ -169,7 +171,7 @@ export default function PongGame() {
|
|||||||
};
|
};
|
||||||
}, [open, inGame, isPlayer, send]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open || !inGame || !isPlayer) return;
|
if (!open || !inGame || !isPlayer) return;
|
||||||
let latest: number | undefined;
|
let latest: number | undefined;
|
||||||
@@ -187,9 +189,12 @@ export default function PongGame() {
|
|||||||
sent = latest;
|
sent = latest;
|
||||||
send('pong:aim', { y: latest });
|
send('pong:aim', { y: latest });
|
||||||
}, AIM_SEND_MS);
|
}, 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);
|
window.addEventListener('pointermove', onMove);
|
||||||
return () => {
|
return () => {
|
||||||
window.clearInterval(id);
|
window.clearInterval(id);
|
||||||
|
window.removeEventListener('pointerdown', onMove);
|
||||||
window.removeEventListener('pointermove', onMove);
|
window.removeEventListener('pointermove', onMove);
|
||||||
};
|
};
|
||||||
}, [open, inGame, isPlayer, send]);
|
}, [open, inGame, isPlayer, send]);
|
||||||
@@ -219,7 +224,7 @@ export default function PongGame() {
|
|||||||
{phase === 'countdown' && <div className="pong-overlay">{state.countdown}</div>}
|
{phase === 'countdown' && <div className="pong-overlay">{state.countdown}</div>}
|
||||||
{phase === 'finished' && <div className="pong-overlay small">🏆 {state.winner}</div>}
|
{phase === 'finished' && <div className="pong-overlay small">🏆 {state.winner}</div>}
|
||||||
</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':
|
case 'idle':
|
||||||
return (
|
return (
|
||||||
<p>
|
<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.
|
do 5 bodů. Když není s kým hrát, přidejte si v lobby bota. Ostatní se mohou dívat.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -295,7 +300,7 @@ export default function PongGame() {
|
|||||||
{icon} Pong běží — zpět do hry
|
{icon} Pong běží — zpět do hry
|
||||||
</button>
|
</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.Header closeButton>
|
||||||
<Modal.Title><h2>{icon} Pong</h2></Modal.Title>
|
<Modal.Title><h2>{icon} Pong</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</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
|
// Nenápadné potvrzení, že logo přijalo sérii kliknutí a čeká na tajné slovo
|
||||||
.navbar-brand.duel-armed {
|
.navbar-brand.duel-armed {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { socket } from "../context/socket";
|
|||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import { getToken } from "../Utils";
|
import { getToken } from "../Utils";
|
||||||
import { useGameIcon } from "../games/themeIcons";
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import { useTouchDevice } from "../hooks/useTouchDevice";
|
||||||
import "./ReactionDuel.scss";
|
import "./ReactionDuel.scss";
|
||||||
|
|
||||||
// Typy musí odpovídat server/src/reactionDuel.ts (stav chodí přes websocket, ne přes OpenAPI)
|
// 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 [myClick, setMyClick] = useState<{ round: number, early: boolean } | null>(null);
|
||||||
const prevPhase = useRef<DuelPhase>('idle');
|
const prevPhase = useRef<DuelPhase>('idle');
|
||||||
const icon = useGameIcon('reaction');
|
const icon = useGameIcon('reaction');
|
||||||
|
const touch = useTouchDevice();
|
||||||
|
|
||||||
const send = useCallback((event: string) => {
|
const send = useCallback((event: string) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -130,7 +132,7 @@ export default function ReactionDuel() {
|
|||||||
<div className="duel-round">Kolo {state.round}/{state.totalRounds}</div>
|
<div className="duel-round">Kolo {state.round}/{state.totalRounds}</div>
|
||||||
<div className="duel-signal">{text}</div>
|
<div className="duel-signal">{text}</div>
|
||||||
{isPlayer
|
{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 className="duel-hint">Sledujete hru.</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -247,7 +249,7 @@ export default function ReactionDuel() {
|
|||||||
{icon} Reakční hra běží — zpět do hry
|
{icon} Reakční hra běží — zpět do hry
|
||||||
</button>
|
</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.Header closeButton>
|
||||||
<Modal.Title><h2>{icon} Reakční hra</h2></Modal.Title>
|
<Modal.Title><h2>{icon} Reakční hra</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
|
|||||||
@@ -16,6 +16,26 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
image-rendering: pixelated;
|
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 {
|
.snake-overlay {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Button, Modal, Table } from "react-bootstrap";
|
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 { socket } from "../context/socket";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import { getToken } from "../Utils";
|
import { getToken } from "../Utils";
|
||||||
import { useGameIcon } from "../games/themeIcons";
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import { useTouchDevice } from "../hooks/useTouchDevice";
|
||||||
|
import TouchButton from "./TouchButton";
|
||||||
import "./SnakeGame.scss";
|
import "./SnakeGame.scss";
|
||||||
|
|
||||||
// Typy musí odpovídat server/src/snakeGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
// 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',
|
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. */
|
/** Vykreslí hrací plochu do canvasu. */
|
||||||
function draw(canvas: HTMLCanvasElement, state: SnakeState, me?: string) {
|
function draw(canvas: HTMLCanvasElement, state: SnakeState, me?: string) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
@@ -114,6 +121,9 @@ export default function SnakeGame() {
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const prevPhase = useRef<SnakePhase>('idle');
|
const prevPhase = useRef<SnakePhase>('idle');
|
||||||
const icon = useGameIcon('snake');
|
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) => {
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -153,6 +163,11 @@ export default function SnakeGame() {
|
|||||||
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
const phase = state?.phase ?? 'idle';
|
const phase = state?.phase ?? 'idle';
|
||||||
const inGame = phase === 'countdown' || phase === 'playing';
|
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
|
// Hráčům se okno otevře samo, jakmile hra začne
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -164,16 +179,39 @@ export default function SnakeGame() {
|
|||||||
|
|
||||||
// Ovládání šipkami a WSAD
|
// Ovládání šipkami a WSAD
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !inGame || !isPlayer) return;
|
if (!controllable) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
const dir = KEY_DIRECTIONS[e.code];
|
const dir = KEY_DIRECTIONS[e.code];
|
||||||
if (!dir) return;
|
if (!dir) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!e.repeat) send('snake:turn', { dir });
|
if (!e.repeat) turn(dir);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('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(() => {
|
useEffect(() => {
|
||||||
if (canvasRef.current && state && (inGame || phase === 'finished')) {
|
if (canvasRef.current && state && (inGame || phase === 'finished')) {
|
||||||
@@ -190,7 +228,9 @@ export default function SnakeGame() {
|
|||||||
|
|
||||||
const renderBoard = () => state && (
|
const renderBoard = () => state && (
|
||||||
<div className="snake-board-wrap">
|
<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>}
|
{phase === 'countdown' && <div className="snake-overlay">{state.countdown}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -221,7 +261,8 @@ export default function SnakeGame() {
|
|||||||
case 'idle':
|
case 'idle':
|
||||||
return (
|
return (
|
||||||
<p>
|
<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;
|
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.
|
hra trvá nejvýš 3 minuty. Hrát se dá i sólo.
|
||||||
</p>
|
</p>
|
||||||
@@ -246,8 +287,16 @@ export default function SnakeGame() {
|
|||||||
<>
|
<>
|
||||||
{renderBoard()}
|
{renderBoard()}
|
||||||
<div className="snake-status">
|
<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>
|
</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()}
|
{renderScores()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -295,7 +344,7 @@ export default function SnakeGame() {
|
|||||||
{icon} Had běží — zpět do hry
|
{icon} Had běží — zpět do hry
|
||||||
</button>
|
</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.Header closeButton>
|
||||||
<Modal.Title><h2>{icon} Had</h2></Modal.Title>
|
<Modal.Title><h2>{icon} Had</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
|
|||||||
@@ -12,9 +12,17 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tetris-own-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.tetris-own {
|
.tetris-own {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
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 {
|
.tetris-board-wrap {
|
||||||
@@ -24,6 +32,22 @@
|
|||||||
.tetris-board {
|
.tetris-board {
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 6px;
|
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 {
|
.tetris-overlay {
|
||||||
@@ -116,6 +140,40 @@
|
|||||||
.tetris-players {
|
.tetris-players {
|
||||||
columns: 2;
|
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 {
|
.tetris-game-modal .tetris-settings {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Button, Form, Modal, Table } from "react-bootstrap";
|
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 { socket } from "../context/socket";
|
||||||
import { useAuth } from "../context/auth";
|
import { useAuth } from "../context/auth";
|
||||||
import { getToken } from "../Utils";
|
import { getToken } from "../Utils";
|
||||||
import { TetrisSession, WIDTH, HEIGHT, pieceCells, Piece } from "../games/tetrisEngine";
|
import { TetrisSession, WIDTH, HEIGHT, pieceCells, Piece } from "../games/tetrisEngine";
|
||||||
import { useGameIcon } from "../games/themeIcons";
|
import { useGameIcon } from "../games/themeIcons";
|
||||||
|
import { useTouchDevice } from "../hooks/useTouchDevice";
|
||||||
|
import TouchButton from "./TouchButton";
|
||||||
import "./TetrisGame.scss";
|
import "./TetrisGame.scss";
|
||||||
|
|
||||||
// Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI)
|
// 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 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',
|
ArrowLeft: 'left', KeyA: 'left',
|
||||||
ArrowRight: 'right', KeyD: 'right',
|
ArrowRight: 'right', KeyD: 'right',
|
||||||
ArrowDown: 'down', KeyS: 'down',
|
ArrowDown: 'down', KeyS: 'down',
|
||||||
@@ -68,6 +74,36 @@ const KEYS: Record<string, 'left' | 'right' | 'down' | 'rotate' | 'rotateBack' |
|
|||||||
KeyZ: 'rotateBack', KeyY: 'rotateBack',
|
KeyZ: 'rotateBack', KeyY: 'rotateBack',
|
||||||
Space: 'drop',
|
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) {
|
function drawCell(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, value: number) {
|
||||||
ctx.fillStyle = COLORS[value];
|
ctx.fillStyle = COLORS[value];
|
||||||
@@ -141,6 +177,8 @@ export default function TetrisGame() {
|
|||||||
const deadline = useRef<number | undefined>(undefined);
|
const deadline = useRef<number | undefined>(undefined);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const icon = useGameIcon('tetris');
|
const icon = useGameIcon('tetris');
|
||||||
|
const touch = useTouchDevice();
|
||||||
|
const gesture = useRef<Gesture | null>(null);
|
||||||
|
|
||||||
const send = useCallback((event: string, extra?: object) => {
|
const send = useCallback((event: string, extra?: object) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -199,6 +237,7 @@ export default function TetrisGame() {
|
|||||||
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
const isPlayer = !!login && !!state?.players.some(p => p.login === login);
|
||||||
const phase = state?.phase ?? 'idle';
|
const phase = state?.phase ?? 'idle';
|
||||||
const inGame = phase === 'countdown' || phase === 'playing';
|
const inGame = phase === 'countdown' || phase === 'playing';
|
||||||
|
const controllable = open && (practice || (phase === 'playing' && isPlayer));
|
||||||
|
|
||||||
// Hráčům se okno otevře samo, jakmile hra začne
|
// Hráčům se okno otevře samo, jakmile hra začne
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -270,27 +309,38 @@ export default function TetrisGame() {
|
|||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [practice, phase, isPlayer, sendSnapshot]);
|
}, [practice, phase, isPlayer, sendSnapshot]);
|
||||||
|
|
||||||
// Ovládání
|
/** Herní akce z klávesnice i dotykového ovládání. */
|
||||||
useEffect(() => {
|
const control = useCallback((action: Action) => {
|
||||||
if (!open || !(practice || (phase === 'playing' && isPlayer))) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
const action = KEYS[e.code];
|
|
||||||
const s = sessionRef.current;
|
const s = sessionRef.current;
|
||||||
if (!action || !s) return;
|
if (!controllable || !s?.alive) return;
|
||||||
e.preventDefault();
|
|
||||||
if (!s.alive) return;
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'left': s.move(-1); break;
|
case 'left': s.move(-1); break;
|
||||||
case 'right': s.move(1); break;
|
case 'right': s.move(1); break;
|
||||||
case 'down': s.softDrop(); break;
|
case 'down': s.softDrop(); break;
|
||||||
case 'rotate': if (!e.repeat) s.rotate(1); break;
|
case 'rotate': s.rotate(1); break;
|
||||||
case 'rotateBack': if (!e.repeat) s.rotate(-1); break;
|
case 'rotateBack': s.rotate(-1); break;
|
||||||
case 'drop': if (!e.repeat) s.hardDrop(); 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 (!controllable) gesture.current = null;
|
||||||
|
}, [controllable]);
|
||||||
|
|
||||||
|
// Ovládání klávesnicí
|
||||||
|
useEffect(() => {
|
||||||
|
if (!controllable) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const action = KEYS[e.code];
|
||||||
|
if (!action || !sessionRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.repeat && !REPEATABLE.has(action)) return;
|
||||||
|
control(action);
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('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
|
// Po skončení hry se session zahodí, ať se příště začíná načisto
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -315,6 +365,56 @@ export default function TetrisGame() {
|
|||||||
setPractice(false);
|
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;
|
if (!login) return null;
|
||||||
|
|
||||||
const session = sessionRef.current;
|
const session = sessionRef.current;
|
||||||
@@ -368,9 +468,12 @@ export default function TetrisGame() {
|
|||||||
const renderPlaying = () => (
|
const renderPlaying = () => (
|
||||||
<div className="tetris-layout">
|
<div className="tetris-layout">
|
||||||
{(isPlayer || practice) && (
|
{(isPlayer || practice) && (
|
||||||
|
<div className="tetris-own-col">
|
||||||
<div className="tetris-own">
|
<div className="tetris-own">
|
||||||
<div className="tetris-board-wrap">
|
<div className="tetris-board-wrap">
|
||||||
<canvas ref={boardRef} className="tetris-board" width={WIDTH * CELL} height={HEIGHT * CELL} />
|
<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>}
|
{!practice && phase === 'countdown' && <div className="tetris-overlay">{state?.countdown}</div>}
|
||||||
{session && !session.alive && <div className="tetris-overlay small">Konec 💀</div>}
|
{session && !session.alive && <div className="tetris-overlay small">Konec 💀</div>}
|
||||||
{flash && <div className="tetris-flash">{flash}</div>}
|
{flash && <div className="tetris-flash">{flash}</div>}
|
||||||
@@ -391,10 +494,27 @@ export default function TetrisGame() {
|
|||||||
<div className="tetris-label">Úroveň</div>
|
<div className="tetris-label">Úroveň</div>
|
||||||
<div className="tetris-value">{session?.level ?? 0}</div>
|
<div className="tetris-value">{session?.level ?? 0}</div>
|
||||||
<div className="tetris-help">
|
<div className="tetris-help">
|
||||||
←→ / AD posun<br />↑ / W otočit<br />↓ / S dolů<br />mezerník shodit
|
{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>
|
</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 && (
|
{others.length > 0 && (
|
||||||
<div className="tetris-opponents">
|
<div className="tetris-opponents">
|
||||||
@@ -526,7 +646,7 @@ export default function TetrisGame() {
|
|||||||
{icon} Tetris běží — zpět do hry
|
{icon} Tetris běží — zpět do hry
|
||||||
</button>
|
</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.Header closeButton>
|
||||||
<Modal.Title><h2>{icon} Tetris</h2></Modal.Title>
|
<Modal.Title><h2>{icon} Tetris</h2></Modal.Title>
|
||||||
</Modal.Header>
|
</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 socketUrl;
|
||||||
let socketPath;
|
let socketPath;
|
||||||
if (process.env.NODE_ENV === 'development') {
|
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;
|
socketPath = undefined;
|
||||||
} else {
|
} else {
|
||||||
socketUrl = `${globalThis.location.host}`;
|
socketUrl = `${globalThis.location.host}`;
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export class TetrisSession {
|
|||||||
score = 0;
|
score = 0;
|
||||||
lines = 0;
|
lines = 0;
|
||||||
alive = true;
|
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 random: () => number;
|
||||||
private bag: number[] = [];
|
private bag: number[] = [];
|
||||||
private pendingGarbage = 0;
|
private pendingGarbage = 0;
|
||||||
@@ -117,6 +119,11 @@ export class TetrisSession {
|
|||||||
if (this.alive) this.tryMove(dx, 0);
|
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() {
|
softDrop() {
|
||||||
if (!this.alive) return;
|
if (!this.alive) return;
|
||||||
if (this.tryMove(0, 1)) this.score += 1;
|
if (this.tryMove(0, 1)) this.score += 1;
|
||||||
@@ -182,6 +189,7 @@ export class TetrisSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.active = piece;
|
this.active = piece;
|
||||||
|
this.pieceNo++;
|
||||||
}
|
}
|
||||||
|
|
||||||
private fits(p: Piece) {
|
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 })));
|
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,
|
* 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.
|
* 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).
|
* 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) {
|
export function useMinigameSecret(href: string) {
|
||||||
const [armed, setArmed] = useState(false);
|
const [armed, setArmed] = useState(false);
|
||||||
const clicks = useRef(0);
|
const clicks = useRef(0);
|
||||||
const timer = useRef<number | undefined>(undefined);
|
const timer = useRef<number | undefined>(undefined);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const onLogoClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
const onLogoClick = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||||
// Otevření v novém tabu apod. necháme prohlížeči
|
// Otevření v novém tabu apod. necháme prohlížeči
|
||||||
@@ -85,6 +90,12 @@ export function useMinigameSecret(href: string) {
|
|||||||
clicks.current++;
|
clicks.current++;
|
||||||
if (clicks.current >= SECRET_CLICKS) {
|
if (clicks.current >= SECRET_CLICKS) {
|
||||||
clicks.current = 0;
|
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);
|
setArmed(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -96,32 +107,44 @@ export function useMinigameSecret(href: string) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!armed) return;
|
if (!armed) return;
|
||||||
|
const input = inputRef.current;
|
||||||
const maxLength = Math.max(HELP_CODE.length, ...MINIGAMES.map(g => g.code.length));
|
const maxLength = Math.max(HELP_CODE.length, ...MINIGAMES.map(g => g.code.length));
|
||||||
let typed = '';
|
let typed = '';
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const check = (text: string) => {
|
||||||
const target = e.target as HTMLElement | null;
|
if (text.endsWith(HELP_CODE)) {
|
||||||
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);
|
setArmed(false);
|
||||||
printHelp();
|
printHelp();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const game = MINIGAMES.find(g => typed.endsWith(g.code));
|
const game = MINIGAMES.find(g => text.endsWith(g.code));
|
||||||
if (game) {
|
if (game) {
|
||||||
setArmed(false);
|
setArmed(false);
|
||||||
game.open();
|
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);
|
const disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
|
input?.addEventListener('input', onInput);
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(disarm);
|
window.clearTimeout(disarm);
|
||||||
window.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey);
|
||||||
|
input?.removeEventListener('input', onInput);
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
input.blur();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [armed]);
|
}, [armed]);
|
||||||
|
|
||||||
useEffect(() => () => window.clearTimeout(timer.current), []);
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
[
|
[
|
||||||
"Tetris: kostky se otáčí proti směru hodinových ručiček kolem svého středu, tyčka a S/Z kostky už jen přepínají mezi vodorovnou a svislou polohou",
|
"Tetris: kostky se otáčí proti směru hodinových ručiček kolem svého středu, tyčka a S/Z kostky už jen přepínají mezi vodorovnou a svislou polohou",
|
||||||
"Tetris: nová kostka se objeví nad hrací plochou, takže ji jde otočit dřív, než do plochy vjede"
|
"Tetris: nová kostka se objeví nad hrací plochou, takže ji jde otočit dřív, než do plochy vjede",
|
||||||
|
"Minihry jdou hrát i na mobilu a tabletu 📱 — Tetris, had i Pong mají dotykové ovládání a okna her se na telefonu roztáhnou přes celou obrazovku"
|
||||||
]
|
]
|
||||||
Reference in new issue
Block a user