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 = { 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(null); const [open, setOpen] = useState(false); const [error, setError] = useState(null); const canvasRef = useRef(null); const prevPhase = useRef('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) => { 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) => { 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 && (
{phase === 'countdown' &&
{state.countdown}
}
); const renderScores = () => ( {ranked.map((p, i) => ( ))}
#HráčJídlaDélka
{phase === 'finished' && i < 3 ? MEDALS[i] : (phase === 'finished' ? i + 1 : '')} {p.login}{p.login === login && ' (vy)'}{!p.alive && inGame && ' 💀'} {p.score} {p.body.length || '—'}
); const renderBody = () => { switch (phase) { case 'idle': return (

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.

); case 'lobby': return ( <>

Lobby založil(a) {state?.host}. Hráči ({state?.players.length}):

    {state?.players.map(p => (
  • {p.login}{p.login === state.host && ' 👑'}
  • ))}
); case 'countdown': case 'playing': return ( <> {renderBoard()}
{isPlayer ? (touch ? 'Táhněte prstem po ploše' : 'Šipky / WSAD') : 'Sledujete hru'} · zbývá {secondsLeft} s
{touch && isPlayer && (
turn('up')}> turn('left')}> turn('right')}> turn('down')}>
)} {renderScores()} ); case 'finished': return ( <> {renderBoard()} {renderScores()} ); } }; const renderFooter = () => { const buttons = []; if (phase === 'idle' || phase === 'finished') { buttons.push(); } if (phase === 'lobby') { if (!isPlayer) { buttons.push(); } else { buttons.push(); if (login === state?.host) { buttons.push(); } } } buttons.push(); return buttons; }; return ( <> {showInvite && ( )} {showReturn && ( )} setOpen(false)} size="xl" fullscreen="sm-down" className="snake-game-modal">

{icon} Had

{renderBody()} {error &&
{error}
}
{renderFooter()}
); }