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
360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
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)
|
|
type Cell = [number, number];
|
|
type Direction = 'up' | 'down' | 'left' | 'right';
|
|
type SnakePhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
|
type SnakePlayer = { login: string, color: number, body: Cell[], alive: boolean, score: number, place?: number };
|
|
type SnakeState = {
|
|
phase: SnakePhase,
|
|
host?: string,
|
|
players: SnakePlayer[],
|
|
food: Cell[],
|
|
tick: number,
|
|
maxTicks: number,
|
|
countdown?: number,
|
|
width: number,
|
|
height: number,
|
|
minPlayers: number,
|
|
};
|
|
|
|
const OPEN_EVENT = 'luncher:open-snake';
|
|
|
|
/** Otevře okno hada. */
|
|
export const openSnakeGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
|
|
|
|
const COLORS = ['#4ade80', '#60a5fa', '#f472b6', '#facc15', '#a78bfa', '#fb923c', '#2dd4bf', '#f87171'];
|
|
const MEDALS = ['🥇', '🥈', '🥉'];
|
|
const TICK_MS = 120;
|
|
|
|
const KEY_DIRECTIONS: Record<string, Direction> = {
|
|
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: '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. */
|
|
function draw(canvas: HTMLCanvasElement, state: SnakeState, me?: string) {
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
const cell = canvas.width / state.width;
|
|
ctx.fillStyle = '#111827';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = '#1f2937';
|
|
for (let x = 0; x < state.width; x++) {
|
|
for (let y = 0; y < state.height; y++) {
|
|
if ((x + y) % 2 === 0) ctx.fillRect(x * cell, y * cell, cell, cell);
|
|
}
|
|
}
|
|
for (const [x, y] of state.food) {
|
|
ctx.fillStyle = '#ef4444';
|
|
ctx.beginPath();
|
|
ctx.arc((x + 0.5) * cell, (y + 0.5) * cell, cell * 0.35, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
for (const p of state.players) {
|
|
const color = COLORS[p.color % COLORS.length];
|
|
// Tělo tlumeně, hlava se kreslí zvlášť až nakonec
|
|
ctx.fillStyle = color;
|
|
ctx.globalAlpha = 0.65;
|
|
p.body.slice(1).forEach(([x, y]) => ctx.fillRect(x * cell + 2, y * cell + 2, cell - 4, cell - 4));
|
|
ctx.globalAlpha = 1;
|
|
if (p.body[0]) drawHead(ctx, p.body, cell, color, p.login === me);
|
|
}
|
|
}
|
|
|
|
/** Hlava: plná barva, zaoblená, přes okraj políčka a s očima ve směru pohybu. */
|
|
function drawHead(ctx: CanvasRenderingContext2D, body: Cell[], cell: number, color: string, mine: boolean) {
|
|
const [x, y] = body[0];
|
|
const neck = body[1];
|
|
const dx = neck ? Math.sign(x - neck[0]) : 1;
|
|
const dy = neck ? Math.sign(y - neck[1]) : 0;
|
|
const cx = (x + 0.5) * cell;
|
|
const cy = (y + 0.5) * cell;
|
|
const size = cell * 1.1;
|
|
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.roundRect(cx - size / 2, cy - size / 2, size, size, cell * 0.35);
|
|
ctx.fill();
|
|
// Vlastní had má bílý obrys hlavy, ostatní tmavý — hlava se tak odliší od těla i pozadí
|
|
ctx.strokeStyle = mine ? '#fff' : '#0b0f19';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
// Oči: posunuté ve směru pohybu, rozložené kolmo na něj
|
|
const forward = cell * 0.18;
|
|
const side = cell * 0.22;
|
|
for (const s of [-1, 1]) {
|
|
const ex = cx + dx * forward + -dy * side * s;
|
|
const ey = cy + dy * forward + dx * side * s;
|
|
ctx.fillStyle = '#fff';
|
|
ctx.beginPath();
|
|
ctx.arc(ex, ey, cell * 0.14, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = '#000';
|
|
ctx.beginPath();
|
|
ctx.arc(ex + dx * cell * 0.05, ey + dy * cell * 0.05, cell * 0.07, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
/** Multiplayerový had — lobby, hrací plocha a výsledky; simulaci drží server. */
|
|
export default function SnakeGame() {
|
|
const auth = useAuth();
|
|
const login = auth?.login;
|
|
const [state, setState] = useState<SnakeState | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
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);
|
|
socket.emit(event, { token: getToken(), ...extra });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onState = (s: SnakeState) => setState(s);
|
|
const onError = (msg: string) => setError(msg);
|
|
const onOpen = () => setOpen(true);
|
|
const sync = () => socket.emit('snake:sync');
|
|
socket.on('snake:state', onState);
|
|
socket.on('snake:error', onError);
|
|
socket.on('connect', sync);
|
|
window.addEventListener(OPEN_EVENT, onOpen);
|
|
sync();
|
|
return () => {
|
|
socket.off('snake:state', onState);
|
|
socket.off('snake:error', onError);
|
|
socket.off('connect', sync);
|
|
window.removeEventListener(OPEN_EVENT, onOpen);
|
|
};
|
|
}, []);
|
|
|
|
// Průběžné snímky hry dostávají jen klienti s otevřeným oknem
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const watch = () => socket.emit('snake:watch');
|
|
watch();
|
|
socket.on('connect', watch);
|
|
return () => {
|
|
socket.off('connect', watch);
|
|
socket.emit('snake:unwatch');
|
|
};
|
|
}, [open]);
|
|
|
|
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(() => {
|
|
if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) {
|
|
setOpen(true);
|
|
}
|
|
prevPhase.current = phase;
|
|
}, [phase, isPlayer]);
|
|
|
|
// Ovládání šipkami a WSAD
|
|
useEffect(() => {
|
|
if (!controllable) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const dir = KEY_DIRECTIONS[e.code];
|
|
if (!dir) return;
|
|
e.preventDefault();
|
|
if (!e.repeat) turn(dir);
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [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')) {
|
|
draw(canvasRef.current, state, login);
|
|
}
|
|
}, [state, inGame, phase, login]);
|
|
|
|
if (!login) return null;
|
|
|
|
const showInvite = !open && phase === 'lobby' && !isPlayer;
|
|
const showReturn = !open && isPlayer && inGame;
|
|
const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99) || b.score - a.score);
|
|
const secondsLeft = state ? Math.max(0, Math.ceil((state.maxTicks - state.tick) * TICK_MS / 1000)) : 0;
|
|
|
|
const renderBoard = () => state && (
|
|
<div className="snake-board-wrap">
|
|
<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>
|
|
);
|
|
|
|
const renderScores = () => (
|
|
<Table size="sm" className="snake-scores">
|
|
<thead>
|
|
<tr><th>#</th><th>Hráč</th><th>Jídla</th><th>Délka</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{ranked.map((p, i) => (
|
|
<tr key={p.login} className={p.alive || phase !== 'playing' ? undefined : 'snake-dead'}>
|
|
<td>{phase === 'finished' && i < 3 ? MEDALS[i] : (phase === 'finished' ? i + 1 : '')}</td>
|
|
<td>
|
|
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
|
|
{p.login}{p.login === login && ' (vy)'}{!p.alive && inGame && ' 💀'}
|
|
</td>
|
|
<td>{p.score}</td>
|
|
<td>{p.body.length || '—'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
);
|
|
|
|
const renderBody = () => {
|
|
switch (phase) {
|
|
case 'idle':
|
|
return (
|
|
<p>
|
|
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>
|
|
);
|
|
case 'lobby':
|
|
return (
|
|
<>
|
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
|
<ul className="snake-players">
|
|
{state?.players.map(p => (
|
|
<li key={p.login}>
|
|
<span className="snake-dot" style={{ background: COLORS[p.color % COLORS.length] }} />
|
|
{p.login}{p.login === state.host && ' 👑'}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
);
|
|
case 'countdown':
|
|
case 'playing':
|
|
return (
|
|
<>
|
|
{renderBoard()}
|
|
<div className="snake-status">
|
|
{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()}
|
|
</>
|
|
);
|
|
case 'finished':
|
|
return (
|
|
<>
|
|
{renderBoard()}
|
|
{renderScores()}
|
|
</>
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderFooter = () => {
|
|
const buttons = [];
|
|
if (phase === 'idle' || phase === 'finished') {
|
|
buttons.push(<Button key="new" onClick={() => send('snake:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
|
}
|
|
if (phase === 'lobby') {
|
|
if (!isPlayer) {
|
|
buttons.push(<Button key="join" onClick={() => send('snake:join')}>Připojit se</Button>);
|
|
} else {
|
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('snake:leave')}>
|
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
|
</Button>);
|
|
if (login === state?.host) {
|
|
buttons.push(<Button key="start" onClick={() => send('snake:start')}
|
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 1)}>Spustit hru</Button>);
|
|
}
|
|
}
|
|
}
|
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
|
return buttons;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{showInvite && (
|
|
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
|
|
{icon} <strong>{state?.host}</strong> zve na hada — připojit se
|
|
</button>
|
|
)}
|
|
{showReturn && (
|
|
<button className="duel-invite snake-invite" onClick={() => setOpen(true)}>
|
|
{icon} Had běží — zpět do hry
|
|
</button>
|
|
)}
|
|
<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>
|
|
<Modal.Body>
|
|
{renderBody()}
|
|
{error && <div className="text-danger mt-2">{error}</div>}
|
|
</Modal.Body>
|
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|