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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user