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 LobbyChat from "./LobbyChat"; import "./TetrisGame.scss"; // Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI) type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished'; type TetrisPlayer = { login: string, board: string, score: number, lines: number, alive: boolean, place?: number }; type TetrisTargeting = 'random' | 'even' | 'payback'; type TetrisWinMode = 'survival' | 'score'; type TetrisSettings = { targeting: TetrisTargeting, winMode: TetrisWinMode, timeLimit: number }; type TetrisState = { phase: TetrisPhase, host?: string, players: TetrisPlayer[], seed?: number, countdown?: number, minPlayers: number, settings: TetrisSettings, remainingMs?: number, }; type GarbageEvent = { target: string, from: string, lines: number }; const OPEN_EVENT = 'luncher:open-tetris'; const TARGETING_LABELS: Record = { random: { label: 'Náhodně (Random)', help: 'útok jde náhodnému soupeři' }, even: { label: 'Rovnoměrně (Even)', help: 'útok jde soupeři, který zatím dostal nejméně odpadu' }, payback: { label: 'Oplácení (Payback)', help: 'útok jde tomu, kdo vás naposledy napadl, jinak rovnoměrně' }, }; const WIN_MODE_LABELS: Record = { survival: 'Poslední ve hře (Last man standing)', score: 'Nejvyšší skóre', }; /** Musí odpovídat TIME_LIMITS na serveru (s). */ const TIME_LIMITS = [120, 180, 300]; const formatTime = (ms: number) => { const total = Math.ceil(ms / 1000); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; }; /** Otevře okno Tetrisu. */ export const openTetrisGame = () => window.dispatchEvent(new Event(OPEN_EVENT)); /** Barvy kostek I, O, T, S, Z, J, L a odpadu (index = hodnota buňky). */ const COLORS = ['', '#22d3ee', '#facc15', '#a855f7', '#22c55e', '#ef4444', '#3b82f6', '#f97316', '#6b7280']; const MEDALS = ['🥇', '🥈', '🥉']; const CELL = 26; const MINI_CELL = 9; /** Jak často se soupeřům posílá deska s padající kostkou (ms). */ const SNAPSHOT_MS = 250; /** * Deska se posílá i časovačem — běží i v neaktivním tabu (na rozdíl od requestAnimationFrame), * takže server pozná, že hráč pořád hraje. Musí být výrazně kratší než PLAYER_TIMEOUT_MS na serveru. */ const HEARTBEAT_MS = 2000; type Action = 'left' | 'right' | 'down' | 'rotate' | 'rotateBack' | 'drop'; const KEYS: Record = { ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right', ArrowDown: 'down', KeyS: 'down', ArrowUp: 'rotate', KeyW: 'rotate', KeyX: 'rotate', KeyZ: 'rotateBack', KeyY: 'rotateBack', Space: 'drop', }; /** Akce, které se opakují při podržení klávesy. */ const REPEATABLE = new Set(['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]; ctx.fillRect(x * size + 1, y * size + 1, size - 2, size - 2); } /** Vykreslí desku zadanou řetězcem (soupeři) nebo polem (vlastní hra). */ function drawBoard(canvas: HTMLCanvasElement, cells: ArrayLike, size: number, piece?: Piece | null, ghost?: Piece | null) { const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.fillStyle = '#111827'; ctx.fillRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < WIDTH * HEIGHT; i++) { const v = Number(cells[i]); if (v) drawCell(ctx, i % WIDTH, Math.floor(i / WIDTH), size, v); } if (ghost) { ctx.globalAlpha = 0.25; pieceCells(ghost).forEach(([x, y]) => y >= 0 && drawCell(ctx, x, y, size, ghost.type + 1)); ctx.globalAlpha = 1; } if (piece) { pieceCells(piece).forEach(([x, y]) => y >= 0 && drawCell(ctx, x, y, size, piece.type + 1)); } } /** Náhled příští kostky. */ function drawNext(canvas: HTMLCanvasElement, type: number) { const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); const cells = pieceCells({ type, rot: 0, x: 0, y: 0 }); const minX = Math.min(...cells.map(c => c[0])); const minY = Math.min(...cells.map(c => c[1])); cells.forEach(([x, y]) => drawCell(ctx, x - minX + 0.5, y - minY + 0.5, 20, type + 1)); } /** Malá deska soupeře. */ function MiniBoard({ player }: { player: TetrisPlayer }) { const ref = useRef(null); useEffect(() => { if (ref.current) drawBoard(ref.current, player.board, MINI_CELL); }, [player.board]); return (
{player.login}{!player.alive && ' 💀'}
{player.score}
); } /** Multiplayerový Tetris (battle) — vlastní deska běží v prohlížeči, server koordinuje hru. */ export default function TetrisGame() { const auth = useAuth(); const login = auth?.login; const [state, setState] = useState(null); const [open, setOpen] = useState(false); const [error, setError] = useState(null); const [flash, setFlash] = useState(null); /** Překreslení postranních údajů (skóre, řádky) po změně v session. */ const [, setVersion] = useState(0); /** Trénink: hra jen v prohlížeči, bez serveru a soupeřů. */ const [practice, setPractice] = useState(false); const sessionRef = useRef(null); const sessionSeed = useRef(undefined); const boardRef = useRef(null); const nextRef = useRef(null); const prevPhase = useRef('idle'); /** Konec hry na skóre v čase prohlížeče (ze zbývajícího času, ať nevadí rozdíl hodin se serverem). */ const deadline = useRef(undefined); const [now, setNow] = useState(() => Date.now()); const icon = useGameIcon('tetris'); const touch = useTouchDevice(); const gesture = useRef(null); const send = useCallback((event: string, extra?: object) => { setError(null); socket.emit(event, { token: getToken(), ...extra }); }, []); const sendSnapshot = useCallback(() => { const s = sessionRef.current; if (s) send('tetris:update', { board: s.snapshot(), score: s.score, lines: s.lines }); }, [send]); useEffect(() => { const onState = (s: TetrisState) => { deadline.current = s.remainingMs !== undefined ? Date.now() + s.remainingMs : undefined; setState(s); }; const onError = (msg: string) => setError(msg); const onOpen = () => setOpen(true); const sync = () => socket.emit('tetris:sync'); socket.on('tetris:state', onState); socket.on('tetris:error', onError); socket.on('connect', sync); window.addEventListener(OPEN_EVENT, onOpen); sync(); return () => { socket.off('tetris:state', onState); socket.off('tetris:error', onError); socket.off('connect', sync); window.removeEventListener(OPEN_EVENT, onOpen); }; }, []); // Průběžné desky soupeřů a útoky dostávají jen klienti s otevřeným oknem useEffect(() => { if (!open) return; const watch = () => socket.emit('tetris:watch'); watch(); socket.on('connect', watch); return () => { socket.off('connect', watch); socket.emit('tetris:unwatch'); }; }, [open]); useEffect(() => { const onGarbage = (e: GarbageEvent) => { if (e.target !== login || !sessionRef.current?.alive) return; sessionRef.current.receiveGarbage(e.lines); setFlash(`⚠️ +${e.lines} od ${e.from}`); window.setTimeout(() => setFlash(null), 1500); }; socket.on('tetris:garbage', onGarbage); return () => { socket.off('tetris:garbage', onGarbage); }; }, [login]); 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(() => { if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) { setPractice(false); setOpen(true); } prevPhase.current = phase; }, [phase, isPlayer]); // Start vlastní hry se seedem od serveru useEffect(() => { if (practice || phase !== 'playing' || !isPlayer || state?.seed === undefined || sessionSeed.current === state.seed) return; sessionSeed.current = state.seed; sessionRef.current = new TetrisSession(state.seed, { onLock: (cleared, attack) => { if (attack > 0) send('tetris:cleared', { count: cleared, sent: attack }); sendSnapshot(); setVersion(v => v + 1); }, onDeath: () => { sendSnapshot(); send('tetris:died'); setVersion(v => v + 1); }, }); setVersion(v => v + 1); }, [practice, phase, isPlayer, state?.seed, send, sendSnapshot]); // Herní smyčka: gravitace, vykreslení a průběžné posílání desky (trénink se při zavřeném okně pozastaví) useEffect(() => { if (practice ? !open : phase !== 'playing') return; if (!sessionRef.current) return; let raf = 0; let lastFall = performance.now(); let lastSnapshot = 0; const loop = (now: number) => { const s = sessionRef.current; if (!s) return; if (s.alive && now - lastFall >= s.gravityMs) { s.tick(); lastFall = now; } if (!practice && s.alive && now - lastSnapshot >= SNAPSHOT_MS) { sendSnapshot(); lastSnapshot = now; } if (boardRef.current) drawBoard(boardRef.current, s.board, CELL, s.active, s.ghost); if (nextRef.current) drawNext(nextRef.current, s.nextType); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame(loop); return () => cancelAnimationFrame(raf); }, [practice, phase, open, sendSnapshot, state?.seed]); // Odpočet zbývajícího času hry na skóre useEffect(() => { if (phase !== 'playing' || state?.settings.winMode !== 'score') return; const id = window.setInterval(() => setNow(Date.now()), 500); return () => window.clearInterval(id); }, [phase, state?.settings.winMode]); // Heartbeat pro server (viz HEARTBEAT_MS) useEffect(() => { if (practice || phase !== 'playing' || !isPlayer) return; const id = window.setInterval(() => { if (sessionRef.current?.alive) sendSnapshot(); }, HEARTBEAT_MS); return () => window.clearInterval(id); }, [practice, phase, isPlayer, sendSnapshot]); /** 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 (!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); return () => window.removeEventListener('keydown', onKey); }, [controllable, control]); // Po skončení hry se session zahodí, ať se příště začíná načisto useEffect(() => { if (!practice && (phase === 'lobby' || phase === 'idle')) { sessionRef.current = null; sessionSeed.current = undefined; } }, [practice, phase]); const startPractice = useCallback(() => { sessionSeed.current = undefined; sessionRef.current = new TetrisSession(Math.floor(Math.random() * 2 ** 31), { onLock: () => setVersion(v => v + 1), onDeath: () => setVersion(v => v + 1), }); setPractice(true); setVersion(v => v + 1); }, []); const stopPractice = useCallback(() => { sessionRef.current = null; 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) => { 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) => { 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) => { 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; // Připojit se jde i během odpočtu před startem const joinable = phase === 'lobby' || phase === 'countdown'; const showInvite = !open && joinable && !isPlayer; const showReturn = !open && isPlayer && inGame; const others = practice ? [] : (state?.players ?? []).filter(p => !(isPlayer && p.login === login)); const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99)); const settings = state?.settings; const isHost = login === state?.host; const remaining = !practice && phase === 'playing' && deadline.current !== undefined ? Math.max(0, deadline.current - now) : undefined; const configure = (change: Partial) => send('tetris:configure', change); const renderSettings = () => settings && (
Míření odpadu {isHost ? ( configure({ targeting: e.target.value as TetrisTargeting })}> {Object.entries(TARGETING_LABELS).map(([value, { label }]) => )} ) :
{TARGETING_LABELS[settings.targeting].label}
} {TARGETING_LABELS[settings.targeting].help}
Vítěz {isHost ? ( configure({ winMode: e.target.value as TetrisWinMode })}> {Object.entries(WIN_MODE_LABELS).map(([value, label]) => )} ) :
{WIN_MODE_LABELS[settings.winMode]}
}
{settings.winMode === 'score' && ( Délka hry {isHost ? ( configure({ timeLimit: Number(e.target.value) })}> {TIME_LIMITS.map(t => )} ) :
{settings.timeLimit / 60} min
}
)}
); const renderPlaying = () => (
{(isPlayer || practice) && (
{!practice && phase === 'countdown' &&
{state?.countdown}
} {session && !session.alive &&
Konec 💀
} {flash &&
{flash}
}
{remaining !== undefined && ( <>
Zbývá
{formatTime(remaining)}
)}
Další
Skóre
{session?.score ?? 0}
Řádky
{session?.lines ?? 0}
Úroveň
{session?.level ?? 0}
{touch ? <>ťuknutí otočí
tažení posouvá
švihnutí dolů shodí : <>←→ / AD posun
↑ / W otočit
↓ / S dolů
mezerník shodit}
{touch && (
control('left')}> control('down')}> control('right')}>
control('rotateBack')}> control('rotate')}> control('drop')}>
)}
)} {others.length > 0 && (
{others.map(p => )}
)}
); const renderBody = () => { if (practice) { return ( <>

🎯 Trénink — hra jen pro vás, nikdo ji nevidí.

{renderPlaying()} ); } switch (phase) { case 'idle': return (

Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz, pošle soupeři 1, 2 nebo 4 odpadní řádky. Smazáním řádků lze zároveň odrazit odpad, který na vás čeká. Zakladatel lobby volí, komu útoky půjdou a zda vyhrává poslední hráč ve hře, nebo nejvyšší skóre po časovém limitu. Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe.

); case 'lobby': return ( <>

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

    {state?.players.map(p =>
  • {p.login}{p.login === state.host && ' 👑'}
  • )}
{state && state.players.length < state.minPlayers &&

Čeká se na další hráče… Pro hru o samotě použijte Trénink.

} {renderSettings()} {isPlayer && } ); case 'countdown': return ( <> {!isPlayer && (

Hra začne za {state?.countdown} s — ještě se můžete připojit.

)} {renderPlaying()} ); case 'playing': return renderPlaying(); case 'finished': return ( <> {settings && (

Hodnocení: {WIN_MODE_LABELS[settings.winMode]} {settings.winMode === 'score' && ` (${settings.timeLimit / 60} min)`} {' · '}míření: {TARGETING_LABELS[settings.targeting].label}

)} {ranked.map((p, i) => ( ))}
#HráčSkóreŘádky
{i < 3 ? MEDALS[i] : i + 1} {p.login}{p.login === login && ' (vy)'} {p.score} {p.lines}
); } }; const renderFooter = () => { const buttons = []; if (practice) { if (session && !session.alive) { buttons.push(); } buttons.push(); buttons.push(); return buttons; } // Trénink jde spustit, kdykoli zrovna nehrajete online hru if (!(isPlayer && (phase === 'lobby' || inGame))) { buttons.push(); } if (phase === 'idle' || phase === 'finished') { buttons.push(); } if (joinable && !isPlayer) { buttons.push(); } if (phase === 'lobby' && isPlayer) { buttons.push(); if (login === state?.host) { buttons.push(); } } buttons.push(); return buttons; }; return ( <> {showInvite && ( )} {showReturn && ( )} setOpen(false)} size="xl" fullscreen="sm-down" className="tetris-game-modal">

{icon} Tetris

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