664 lines
30 KiB
TypeScript
664 lines
30 KiB
TypeScript
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<TetrisTargeting, { label: string, help: string }> = {
|
|
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<TetrisWinMode, string> = {
|
|
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<string, Action> = {
|
|
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<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];
|
|
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<number | string>, 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<HTMLCanvasElement>(null);
|
|
useEffect(() => {
|
|
if (ref.current) drawBoard(ref.current, player.board, MINI_CELL);
|
|
}, [player.board]);
|
|
return (
|
|
<div className={`tetris-mini ${player.alive ? '' : 'dead'}`}>
|
|
<canvas ref={ref} width={WIDTH * MINI_CELL} height={HEIGHT * MINI_CELL} />
|
|
<div className="tetris-mini-name">{player.login}{!player.alive && ' 💀'}</div>
|
|
<div className="tetris-mini-score">{player.score}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 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<TetrisState | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [flash, setFlash] = useState<string | null>(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<TetrisSession | null>(null);
|
|
const sessionSeed = useRef<number | undefined>(undefined);
|
|
const boardRef = useRef<HTMLCanvasElement>(null);
|
|
const nextRef = useRef<HTMLCanvasElement>(null);
|
|
const prevPhase = useRef<TetrisPhase>('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<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);
|
|
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<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;
|
|
// 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<TetrisSettings>) => send('tetris:configure', change);
|
|
|
|
const renderSettings = () => settings && (
|
|
<div className="tetris-settings">
|
|
<Form.Group>
|
|
<Form.Label>Míření odpadu</Form.Label>
|
|
{isHost ? (
|
|
<Form.Select size="sm" value={settings.targeting}
|
|
onChange={e => configure({ targeting: e.target.value as TetrisTargeting })}>
|
|
{Object.entries(TARGETING_LABELS).map(([value, { label }]) => <option key={value} value={value}>{label}</option>)}
|
|
</Form.Select>
|
|
) : <div><strong>{TARGETING_LABELS[settings.targeting].label}</strong></div>}
|
|
<Form.Text>{TARGETING_LABELS[settings.targeting].help}</Form.Text>
|
|
</Form.Group>
|
|
<Form.Group>
|
|
<Form.Label>Vítěz</Form.Label>
|
|
{isHost ? (
|
|
<Form.Select size="sm" value={settings.winMode}
|
|
onChange={e => configure({ winMode: e.target.value as TetrisWinMode })}>
|
|
{Object.entries(WIN_MODE_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
|
</Form.Select>
|
|
) : <div><strong>{WIN_MODE_LABELS[settings.winMode]}</strong></div>}
|
|
</Form.Group>
|
|
{settings.winMode === 'score' && (
|
|
<Form.Group>
|
|
<Form.Label>Délka hry</Form.Label>
|
|
{isHost ? (
|
|
<Form.Select size="sm" value={settings.timeLimit}
|
|
onChange={e => configure({ timeLimit: Number(e.target.value) })}>
|
|
{TIME_LIMITS.map(t => <option key={t} value={t}>{t / 60} min</option>)}
|
|
</Form.Select>
|
|
) : <div><strong>{settings.timeLimit / 60} min</strong></div>}
|
|
</Form.Group>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const renderPlaying = () => (
|
|
<div className="tetris-layout">
|
|
{(isPlayer || practice) && (
|
|
<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 && (
|
|
<div className="tetris-opponents">
|
|
{others.map(p => <MiniBoard key={p.login} player={p} />)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const renderBody = () => {
|
|
if (practice) {
|
|
return (
|
|
<>
|
|
<p className="text-muted text-center">🎯 Trénink — hra jen pro vás, nikdo ji nevidí.</p>
|
|
{renderPlaying()}
|
|
</>
|
|
);
|
|
}
|
|
switch (phase) {
|
|
case 'idle':
|
|
return (
|
|
<p>
|
|
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.
|
|
</p>
|
|
);
|
|
case 'lobby':
|
|
return (
|
|
<>
|
|
<p>Lobby založil(a) <strong>{state?.host}</strong>. Hráči ({state?.players.length}):</p>
|
|
<ul className="tetris-players">
|
|
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
|
|
</ul>
|
|
{state && state.players.length < state.minPlayers &&
|
|
<p className="text-muted">Čeká se na další hráče… Pro hru o samotě použijte Trénink.</p>}
|
|
{renderSettings()}
|
|
{isPlayer && <LobbyChat game="tetris" />}
|
|
</>
|
|
);
|
|
case 'countdown':
|
|
return (
|
|
<>
|
|
{!isPlayer && (
|
|
<p className="text-center">
|
|
Hra začne za <strong>{state?.countdown} s</strong> — ještě se můžete připojit.
|
|
</p>
|
|
)}
|
|
{renderPlaying()}
|
|
</>
|
|
);
|
|
case 'playing':
|
|
return renderPlaying();
|
|
case 'finished':
|
|
return (
|
|
<>
|
|
{settings && (
|
|
<p className="text-muted">
|
|
Hodnocení: {WIN_MODE_LABELS[settings.winMode]}
|
|
{settings.winMode === 'score' && ` (${settings.timeLimit / 60} min)`}
|
|
{' · '}míření: {TARGETING_LABELS[settings.targeting].label}
|
|
</p>
|
|
)}
|
|
<Table size="sm" className="tetris-results">
|
|
<thead>
|
|
<tr><th>#</th><th>Hráč</th><th>Skóre</th><th>Řádky</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{ranked.map((p, i) => (
|
|
<tr key={p.login}>
|
|
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
|
|
<td>{p.login}{p.login === login && ' (vy)'}</td>
|
|
<td>{p.score}</td>
|
|
<td>{p.lines}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
</>
|
|
);
|
|
}
|
|
};
|
|
|
|
const renderFooter = () => {
|
|
const buttons = [];
|
|
if (practice) {
|
|
if (session && !session.alive) {
|
|
buttons.push(<Button key="again" onClick={startPractice}>Znovu</Button>);
|
|
}
|
|
buttons.push(<Button key="stop" variant="outline-secondary" onClick={stopPractice}>Ukončit trénink</Button>);
|
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
|
return buttons;
|
|
}
|
|
// Trénink jde spustit, kdykoli zrovna nehrajete online hru
|
|
if (!(isPlayer && (phase === 'lobby' || inGame))) {
|
|
buttons.push(<Button key="practice" variant="outline-primary" onClick={startPractice}>Trénink</Button>);
|
|
}
|
|
if (phase === 'idle' || phase === 'finished') {
|
|
buttons.push(<Button key="new" onClick={() => send('tetris:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
|
|
}
|
|
if (joinable && !isPlayer) {
|
|
buttons.push(<Button key="join" onClick={() => send('tetris:join')}>Připojit se</Button>);
|
|
}
|
|
if (phase === 'lobby' && isPlayer) {
|
|
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tetris:leave')}>
|
|
{login === state?.host ? 'Zrušit lobby' : 'Odejít'}
|
|
</Button>);
|
|
if (login === state?.host) {
|
|
buttons.push(<Button key="start" onClick={() => send('tetris:start')}
|
|
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
|
|
}
|
|
}
|
|
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
|
|
return buttons;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{showInvite && (
|
|
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
|
|
{icon} {phase === 'countdown'
|
|
? <>Tetris začíná za <strong>{state?.countdown} s</strong> — připojit se</>
|
|
: <><strong>{state?.host}</strong> zve na Tetris — připojit se</>}
|
|
</button>
|
|
)}
|
|
{showReturn && (
|
|
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
|
|
{icon} Tetris běží — zpět do hry
|
|
</button>
|
|
)}
|
|
<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>
|
|
<Modal.Body>
|
|
{renderBody()}
|
|
{error && <div className="text-danger mt-2">{error}</div>}
|
|
</Modal.Body>
|
|
<Modal.Footer>{renderFooter()}</Modal.Footer>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|