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