) => [...pressed].some(k => keys.has(k));
- const update = () => {
- const x = (any(RIGHT_KEYS) ? 1 : 0) - (any(LEFT_KEYS) ? 1 : 0);
- const y = (any(DOWN_KEYS) ? 1 : 0) - (any(UP_KEYS) ? 1 : 0);
- const fire = any(FIRE_KEYS) || mouseFire;
- const input = controls === 'tank' ? { drive: -y, turn: x, fire } : { dx: x, dy: y, fire };
- const key = JSON.stringify(input);
- if (key === sent) return;
- sent = key;
- send('tanks:input', input);
- };
- const onDown = (e: KeyboardEvent) => {
- if (!GAME_KEYS.has(e.code)) return;
- // Mezerník by jinak "klikl" na tlačítko s fokusem
- e.preventDefault();
- if (pressed.has(e.code)) return;
- pressed.add(e.code);
- if (MINE_KEYS.has(e.code)) send('tanks:mine');
- update();
- };
- const onUp = (e: KeyboardEvent) => {
- if (GAME_KEYS.has(e.code)) e.preventDefault();
- if (!pressed.delete(e.code)) return;
- update();
- };
- // Levé tlačítko nad hrací plochou střílí, dokud se nepustí (puštění kdekoli); pravé položí minu
- const onMouseDown = (e: MouseEvent) => {
- if (e.target !== canvasRef.current) return;
- if (e.button === 2) {
- e.preventDefault();
- send('tanks:mine');
- return;
- }
- if (e.button !== 0) return;
- e.preventDefault();
- mouseFire = true;
- update();
- };
- const onMouseUp = (e: MouseEvent) => {
- if (e.button !== 0 || !mouseFire) return;
- mouseFire = false;
- update();
- };
- const onContextMenu = (e: MouseEvent) => {
- if (e.target === canvasRef.current) e.preventDefault();
- };
- const onBlur = () => {
- pressed.clear();
- mouseFire = false;
- update();
- };
- (document.activeElement as HTMLElement | null)?.blur?.();
- window.addEventListener('keydown', onDown);
- window.addEventListener('keyup', onUp);
- window.addEventListener('mousedown', onMouseDown);
- window.addEventListener('mouseup', onMouseUp);
- window.addEventListener('contextmenu', onContextMenu);
- window.addEventListener('blur', onBlur);
- window.addEventListener('pagehide', onBlur);
- return () => {
- // Server si pamatuje poslední stav — bez puštění by tank jel dál (zavřené okno, pád stránky)
- onBlur();
- window.removeEventListener('pagehide', onBlur);
- window.removeEventListener('keydown', onDown);
- window.removeEventListener('keyup', onUp);
- window.removeEventListener('mousedown', onMouseDown);
- window.removeEventListener('mouseup', onMouseUp);
- window.removeEventListener('contextmenu', onContextMenu);
- window.removeEventListener('blur', onBlur);
- };
- }, [open, inGame, isPlayer, send, controls]);
-
- const changeControls = (value: Controls) => {
- setControls(value);
- try {
- localStorage.setItem(CONTROLS_KEY, value);
- } catch {
- // localStorage nedostupný
- }
- };
-
- // Míření myší: kurzor nad hrací plochou natáčí věž, mimo ni se věž vrátí do směru jízdy
- useEffect(() => {
- if (!open || !inGame || !isPlayer) return;
- let latest: [number, number] | null = null;
- let sent = '';
- const onMove = (e: PointerEvent) => {
- const canvas = canvasRef.current;
- if (!canvas) return;
- const rect = canvas.getBoundingClientRect();
- const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
- latest = inside
- ? [Math.round((e.clientX - rect.left) / rect.width * canvas.width), Math.round((e.clientY - rect.top) / rect.height * canvas.height)]
- : null;
- };
- const id = window.setInterval(() => {
- const key = JSON.stringify(latest);
- if (key === sent) return;
- sent = key;
- send('tanks:aim', latest ? { x: latest[0], y: latest[1] } : { x: null });
- }, AIM_SEND_MS);
- window.addEventListener('pointermove', onMove);
- return () => {
- window.clearInterval(id);
- window.removeEventListener('pointermove', onMove);
- };
- }, [open, inGame, isPlayer, send]);
-
- // Záblesky výstřelů: podle změny počítadla výstřelů u hráče
- useEffect(() => {
- if (!state) return;
- const now = performance.now();
- for (const p of state.players) {
- const before = prevShots.current.get(p.login);
- if (before !== undefined && p.shots > before && state.phase === 'playing') {
- flashes.current.push({ login: p.login, start: now });
- }
- prevShots.current.set(p.login, p.shots);
- }
- flashes.current = flashes.current.filter(f => now - f.start < FLASH_MS);
- }, [state]);
-
- useEffect(() => {
- if (!state || !(inGame || phase === 'finished')) return;
- const draw = () => canvasRef.current && drawTanks(canvasRef.current, state, flashes.current, login);
- draw();
- // Při odpočtu chodí stav jen jednou za sekundu — značka vlastního tanku se animuje překreslováním
- if (phase !== 'countdown') return;
- let frame = requestAnimationFrame(function loop() {
- draw();
- frame = requestAnimationFrame(loop);
- });
- return () => cancelAnimationFrame(frame);
- }, [state, inGame, phase, login]);
-
- if (!login) return null;
-
- const mode = MODES.find(m => m.value === state?.mode) ?? MODES[0];
- const mapOf = (id?: string) => state?.maps.find(m => m.id === id);
- const mapLabel = (m?: { name: string, biome: keyof typeof BIOMES }) => m ? `${BIOMES[m.biome].icon} ${m.name}` : '🎲 Náhodná';
- const mapName = mapLabel(state?.mapId === 'random' ? undefined : mapOf(state?.mapId));
- const showInvite = !open && phase === 'lobby' && !isPlayer && (state?.players.length ?? 0) < (state?.maxPlayers ?? 0);
- const showReturn = !open && isPlayer && inGame;
- const ranked = [...(state?.players ?? [])].sort((a, b) => phase === 'finished'
- ? (a.place ?? 99) - (b.place ?? 99)
- : state?.mode === 'lastman'
- ? (b.lives ?? 0) - (a.lives ?? 0) || b.kills - a.kills
- : b.score - a.score || a.deaths - b.deaths);
- const teamDot = (team: 0 | 1) => ;
-
- const winnerText = () => {
- if (!state?.teams) return `🏆 ${ranked[0]?.login}`;
- if (state.winnerTeam === undefined || state.winnerTeam < 0) return '🤝 Remíza';
- return `🏆 Vyhrává tým ${TEAM_NAMES[state.winnerTeam]}`;
- };
-
- const renderBoard = () => state && (
-
- {state.teams && state.teamScores && (
-
- {TEAM_NAMES[0]}
- {state.teamScores[0]} : {state.teamScores[1]}
- {TEAM_NAMES[1]}
-
- )}
-
-
- {phase === 'countdown' &&
{state.countdown}
}
- {phase === 'playing' && me && !me.alive && me.respawnIn !== undefined && (
-
Zničen — návrat za {Math.ceil(me.respawnIn / 1000)} s
- )}
- {phase === 'playing' && me && !me.alive && me.respawnIn === undefined && (
-
Vypadli jste — sledujete hru
- )}
- {phase === 'finished' &&
{winnerText()}
}
-
- {inGame && me?.alive && (
-
- Výbava: {equipment(me).length ? equipment(me).join(' ') : nic — sbírejte bonusy }
-
- )}
-
- {mode.label}{state.teams && ' v týmech'}{state.bounce && ' · odrazy'}{state.hardcore && ' · 🌑 hardcore'} · {mapName}{inGame && ` · zbývá ${formatTime(state.timeLeftMs)}`}
- {inGame && (isPlayer
- ? ` · ${CONTROLS.find(c => c.value === controls)!.hint}, myš míření, klik / mezerník střelba, E / pravé tlačítko mina`
- : ' · sledujete hru')}
-
- {inGame && isPlayer && renderControls()}
- {inGame && (
-
- {(Object.entries(PICKUPS) as [PickupType, typeof PICKUPS[PickupType]][])
- .filter(([type]) => type !== 'heart' || state.mode === 'lastman')
- .map(([type, p]) => {p.icon} {p.label} )}
-
- )}
- {renderScores()}
-
- );
-
- /** Volba ovládání — osobní nastavení hráče, pamatuje si ho prohlížeč. */
- const renderControls = () => (
-
- Ovládání
- {CONTROLS.map(c => (
-
changeControls(c.value)} />
- ))}
-
- );
-
- const renderScores = () => (
-
-
-
- #
- Hráč
- {state?.mode === 'lastman' ? Životy : Body }
- Zničil(a)
- Zničen(a)
-
-
-
- {ranked.map((p, i) => (
-
- {phase === 'finished' ? (i < 3 ? MEDALS[i] : i + 1) : ''}
-
-
- {p.login}{p.login === login && ' (vy)'}
- {state?.teams && p.team !== undefined && · {TEAM_NAMES[p.team]} }
- {p.alive && inGame && equipment(p).length > 0 && {equipment(p).join(' ')} }
-
- {state?.mode === 'lastman' ? '❤️'.repeat(p.lives ?? 0) || '💀' : p.score}
- {p.kills}
- {p.deaths}
-
- ))}
-
-
- );
-
- const renderPlayer = (p: TankPlayer) => (
-
-
- {p.login}{p.login === state?.host && ' 👑'}
- {state?.teams && (isHost || p.login === login) && (
- send('tanks:team', { target: p.login })}>⇄
- )}
-
- );
-
- const renderLobby = () => state && (
- <>
- Lobby založil(a) {state.host} . Hráči ({state.players.length}/{state.maxPlayers}):
- {state.teams ? (
-
- {([0, 1] as const).map(team => {
- const members = state.players.filter(p => p.team === team);
- return (
-
-
{teamDot(team)}{TEAM_NAMES[team]} ({members.length}/{MAX_TEAM_SIZE})
-
{members.map(renderPlayer)}
-
- );
- })}
-
- ) : (
- {state.players.map(renderPlayer)}
- )}
- {isHost ? (
- <>
-
-
- Režim
- send('tanks:configure', { mode: e.target.value })}>
- {MODES.map(m => {m.label} )}
-
-
-
- Mapa
- send('tanks:configure', { mapId: e.target.value })}>
- {mapLabel()}
- {state.maps.map(m => {mapLabel(m)} )}
-
-
-
-
-
Hráči
-
-
send('tanks:configure', { teams: false })} />
- send('tanks:configure', { teams: true })} />
-
-
Střely
-
send('tanks:configure', { bounce: e.target.checked })} />
- Hardcore
- send('tanks:configure', { hardcore: e.target.checked })} />
- {(state.players.length < state.maxPlayers || state.players.some(p => p.bot)) && (
- <>
- Boti
-
- {state.players.length < state.maxPlayers && (
- <>
-
setBotLevel(e.target.value as BotLevel)}>
- {BOT_LEVELS.map(l => {l.label} )}
-
- send('tanks:addBot', { level: botLevel })}>
- Přidat bota
-
- >
- )}
- {state.players.some(p => p.bot) && (
- send('tanks:removeBots')}>Odebrat boty
- )}
-
- >
- )}
-
- >
- ) : (
-
- Režim: {mode.label}{state.teams && ' v týmech'}{state.bounce && ', odrazy'}{state.hardcore && ', 🌑 hardcore'}
- {' · '}mapa: {mapName}
-
- )}
-
- {mode.description}{state.teams && ' Spoluhráči se navzájem nezraní, týmy začínají každý na své půlce mapy.'}
- {state.bounce && ' Střely se odráží od zdí — pozor na vlastní!'}
- {state.hardcore && ' V aréně je tma: vidíte jen okolí tanku a kužel reflektoru ve směru věže, světlo zastaví zdi a bedny.'
- + (state.teams ? ' Co osvítí spoluhráč, vidíte také.' : '') + ' Výbuchy a výstřely prosvítí i tmou.'}
-
- {isPlayer && renderControls()}
- {isPlayer && }
- >
- );
-
- const renderBody = () => {
- switch (phase) {
- case 'idle':
- return (
- <>
-
- Tanková bitva pro více hráčů. Jezdí se šipkami nebo WSAD (směrově, nebo po tankovém —
- vpřed/vzad a otáčení), věž míří za myší a střílí se kliknutím nebo mezerníkem. Bedny se dají rozstřílet, přes vodu se nedá projet (ale dá se
- přes ni střílet) a bláto, sníh, písek nebo porost zpomalují. Hraje se na poslední přeživší
- nebo na čas, sólo i v týmech, volitelně s odrážením střel od zdí nebo potmě (hardcore);
- když není s kým hrát, přidejte si do lobby boty.
-
- Bonusy:
-
- {Object.values(PICKUPS).map(p => {p.icon} {p.label} )}
-
- {renderControls()}
- >
- );
- case 'lobby':
- return renderLobby();
- default:
- return renderBoard();
- }
- };
-
- const startBlocked = () => {
- if (!state) return true;
- if (state.players.length < state.minPlayers) return true;
- return state.teams && state.minPlayers > 1 && [0, 1].some(t => !state.players.some(p => p.team === t));
- };
-
- const renderFooter = () => {
- const buttons = [];
- if (phase === 'idle' || phase === 'finished') {
- buttons.push( send('tanks:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'} );
- }
- if (phase === 'lobby') {
- if (!isPlayer) {
- buttons.push( send('tanks:join')}
- disabled={(state?.players.length ?? 0) >= (state?.maxPlayers ?? 0)}>Připojit se );
- } else {
- buttons.push( send('tanks:leave')}>
- {isHost ? 'Zrušit lobby' : 'Odejít'}
- );
- if (isHost) {
- buttons.push( send('tanks:start')} disabled={startBlocked()}>Spustit hru );
- }
- }
- }
- buttons.push( setOpen(false)}>Zavřít );
- return buttons;
- };
-
- return (
- <>
- {showInvite && (
- setOpen(true)}>
- {icon} {state?.host} zve na tanky — připojit se
-
- )}
- {showReturn && (
- setOpen(true)}>
- {icon} Tanky běží — zpět do hry
-
- )}
- setOpen(false)} size="xl" className="tanks-game-modal">
-
- {icon} Tanky
-
-
- {renderBody()}
- {error && {error}
}
-
- {renderFooter()}
-
- >
- );
-}
diff --git a/client/src/components/TetrisGame.scss b/client/src/components/TetrisGame.scss
deleted file mode 100644
index b96e702..0000000
--- a/client/src/components/TetrisGame.scss
+++ /dev/null
@@ -1,193 +0,0 @@
-// Pozvánka Tetrisu nad pozvánkami ostatních her, aby se nepřekrývaly
-.duel-invite.tetris-invite {
- bottom: 120px;
-}
-
-.tetris-game-modal {
- .tetris-layout {
- display: flex;
- flex-wrap: wrap;
- gap: 24px;
- justify-content: center;
- align-items: flex-start;
- }
-
- .tetris-own-col {
- display: flex;
- flex-direction: column;
- gap: 12px;
- }
-
- .tetris-own {
- display: flex;
- gap: 16px;
- // Ovládání pod deskou může být na tabletu širší než deska s postranním panelem
- align-self: center;
- }
-
- .tetris-board-wrap {
- position: relative;
- }
-
- .tetris-board {
- display: block;
- border-radius: 6px;
- // Gesta prstem nesmí posouvat ani přibližovat stránku
- touch-action: none;
- user-select: none;
- -webkit-user-select: none;
- -webkit-touch-callout: none;
- }
-
- .tetris-touch-pad {
- display: flex;
- justify-content: space-between;
- gap: 12px;
- }
-
- .tetris-touch-group {
- display: flex;
- gap: calc(var(--touch-btn-size) * 0.12);
- }
-
- .tetris-overlay {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 5rem;
- font-weight: 700;
- color: #fff;
- text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
- background: rgba(0, 0, 0, 0.25);
- pointer-events: none;
-
- &.small {
- font-size: 2.2rem;
- }
- }
-
- .tetris-flash {
- position: absolute;
- top: 8px;
- left: 0;
- right: 0;
- text-align: center;
- font-weight: 700;
- color: #fca5a5;
- text-shadow: 0 1px 6px rgba(0, 0, 0, 0.8);
- pointer-events: none;
- }
-
- .tetris-side {
- min-width: 110px;
- }
-
- .tetris-label {
- font-size: 0.8rem;
- text-transform: uppercase;
- opacity: 0.6;
- margin-top: 10px;
- }
-
- .tetris-value {
- font-size: 1.4rem;
- font-weight: 700;
- }
-
- .tetris-help {
- margin-top: 16px;
- font-size: 0.8rem;
- opacity: 0.7;
- line-height: 1.5;
- }
-
- .tetris-opponents {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- max-width: 440px;
- }
-
- .tetris-mini {
- text-align: center;
-
- canvas {
- display: block;
- border-radius: 4px;
- }
-
- &.dead canvas {
- opacity: 0.35;
- }
- }
-
- .tetris-mini-name {
- font-size: 0.8rem;
- font-weight: 600;
- max-width: 90px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .tetris-mini-score {
- font-size: 0.75rem;
- opacity: 0.7;
- }
-
- .tetris-players {
- columns: 2;
- }
-
- // Na dotykovém displeji se deska zmenší, aby se i s ovládáním vešla na výšku telefonu
- @media (any-pointer: coarse) {
- .tetris-own {
- gap: 12px;
- }
-
- .tetris-board {
- width: auto;
- // Výška okna bez hlavičky, patičky, odstupů a řady tlačítek
- height: clamp(280px, calc(100dvh - 278px - var(--touch-btn-size)), #{20 * 26px});
- }
-
- .tetris-side {
- min-width: 90px;
- }
- }
-
- @media (max-width: 575.98px) {
- .modal-body {
- padding: 12px;
- }
-
- .tetris-layout {
- gap: 12px;
- }
- }
-}
-
-// Bootstrap dává oknu mezi 576 a 992 px (tablet na výšku) jen 500 px a větší dotyková tlačítka by se nevešla
-@media (min-width: 576px) and (max-width: 991.98px) {
- .tetris-game-modal .modal-dialog {
- --bs-modal-width: 640px;
- }
-}
-
-.tetris-game-modal .tetris-settings {
- display: flex;
- flex-wrap: wrap;
- gap: 16px;
- max-width: 640px;
-
- > div {
- flex: 1 1 180px;
- }
-
- .form-label {
- margin-bottom: 2px;
- font-weight: 600;
- }
-}
diff --git a/client/src/components/TetrisGame.tsx b/client/src/components/TetrisGame.tsx
deleted file mode 100644
index 33473e6..0000000
--- a/client/src/components/TetrisGame.tsx
+++ /dev/null
@@ -1,663 +0,0 @@
-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 }]) => {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]) => {label} )}
-
- ) : {WIN_MODE_LABELS[settings.winMode]}
}
-
- {settings.winMode === 'score' && (
-
- Délka hry
- {isHost ? (
- configure({ timeLimit: Number(e.target.value) })}>
- {TIME_LIMITS.map(t => {t / 60} min )}
-
- ) : {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}
-
- )}
-
-
- # Hráč Skóre Řádky
-
-
- {ranked.map((p, i) => (
-
- {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(Znovu );
- }
- buttons.push(Ukončit trénink );
- buttons.push( setOpen(false)}>Zavřít );
- return buttons;
- }
- // Trénink jde spustit, kdykoli zrovna nehrajete online hru
- if (!(isPlayer && (phase === 'lobby' || inGame))) {
- buttons.push(Trénink );
- }
- if (phase === 'idle' || phase === 'finished') {
- buttons.push( send('tetris:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'} );
- }
- if (joinable && !isPlayer) {
- buttons.push( send('tetris:join')}>Připojit se );
- }
- if (phase === 'lobby' && isPlayer) {
- buttons.push( send('tetris:leave')}>
- {login === state?.host ? 'Zrušit lobby' : 'Odejít'}
- );
- if (login === state?.host) {
- buttons.push( send('tetris:start')}
- disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru );
- }
- }
- buttons.push( setOpen(false)}>Zavřít );
- return buttons;
- };
-
- return (
- <>
- {showInvite && (
- setOpen(true)}>
- {icon} {phase === 'countdown'
- ? <>Tetris začíná za {state?.countdown} s — připojit se>
- : <>{state?.host} zve na Tetris — připojit se>}
-
- )}
- {showReturn && (
- setOpen(true)}>
- {icon} Tetris běží — zpět do hry
-
- )}
- setOpen(false)} size="xl" fullscreen="sm-down" className="tetris-game-modal">
-
- {icon} Tetris
-
-
- {renderBody()}
- {error && {error}
}
-
- {renderFooter()}
-
- >
- );
-}
diff --git a/client/src/components/TouchButton.scss b/client/src/components/TouchButton.scss
deleted file mode 100644
index 400c69a..0000000
--- a/client/src/components/TouchButton.scss
+++ /dev/null
@@ -1,37 +0,0 @@
-// Velikost dotykových tlačítek — podle ní se řídí i rozložení ovládání v jednotlivých hrách
-:root {
- --touch-btn-size: 52px;
-}
-
-// Tablet (na rozdíl od telefonu na šířku je vysoký i široký) — na velkém displeji jsou malá tlačítka špatně trefitelná
-@media (any-pointer: coarse) and (min-width: 700px) and (min-height: 600px) {
- :root {
- --touch-btn-size: 76px;
- }
-}
-
-// Dotyková tlačítka miniher — bez zoomu dvojitým ťuknutím, výběru textu a kontextové nabídky při podržení
-.touch-btn {
- display: flex;
- align-items: center;
- justify-content: center;
- min-width: var(--touch-btn-size);
- height: var(--touch-btn-size);
- padding: 0 10px;
- border: 1px solid var(--luncher-border, rgba(127, 127, 127, 0.4));
- border-radius: calc(var(--touch-btn-size) * 0.23);
- background: var(--bs-tertiary-bg);
- color: var(--luncher-text, inherit);
- font-size: calc(var(--touch-btn-size) * 0.45);
- line-height: 1;
- touch-action: none;
- user-select: none;
- -webkit-user-select: none;
- -webkit-touch-callout: none;
- -webkit-tap-highlight-color: transparent;
-
- &:active {
- background: var(--luncher-primary, #198754);
- color: #fff;
- }
-}
diff --git a/client/src/components/TouchButton.tsx b/client/src/components/TouchButton.tsx
deleted file mode 100644
index cca5419..0000000
--- a/client/src/components/TouchButton.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { ReactNode, useEffect, useRef } from "react";
-import "./TouchButton.scss";
-
-/** Prodleva před opakováním a interval opakování při podržení tlačítka (ms), podobně jako u klávesnice. */
-const REPEAT_DELAY_MS = 170;
-const REPEAT_MS = 50;
-
-type Props = {
- label: string,
- onPress: () => void,
- /** Při podržení se akce opakuje (posun, pád). */
- repeat?: boolean,
- className?: string,
- children: ReactNode,
-};
-
-/**
- * Tlačítko dotykového ovládání miniher. Reaguje hned na dotyk (ne až na click) a je mimo pořadí
- * fokusu, aby mezerník nebo šipky z klávesnice nespouštěly tlačítko místo hry.
- */
-export default function TouchButton({ label, onPress, repeat, className, children }: Props) {
- const timer = useRef(undefined);
- const onPressRef = useRef(onPress);
- onPressRef.current = onPress;
-
- const stop = () => {
- window.clearTimeout(timer.current);
- timer.current = undefined;
- };
- useEffect(() => stop, []);
-
- const onPointerDown = (e: React.PointerEvent) => {
- e.preventDefault();
- e.currentTarget.setPointerCapture(e.pointerId);
- stop();
- onPressRef.current();
- if (!repeat) return;
- const tick = () => {
- onPressRef.current();
- timer.current = window.setTimeout(tick, REPEAT_MS);
- };
- timer.current = window.setTimeout(tick, REPEAT_DELAY_MS);
- };
-
- return (
- e.preventDefault()}>
- {children}
-
- );
-}
diff --git a/client/src/games/tankRender.ts b/client/src/games/tankRender.ts
deleted file mode 100644
index 601b905..0000000
--- a/client/src/games/tankRender.ts
+++ /dev/null
@@ -1,948 +0,0 @@
-/**
- * Vykreslování tanků do canvasu: mapa podle prostředí (biomu), tanky, střely, miny, bonusy,
- * hořící vraky, výbuchy a záblesky výstřelů. Stav chodí ze serveru (server/src/tankGame.ts).
- */
-
-// Typy musí odpovídat server/src/tankGame.ts (stav chodí přes websocket, ne přes OpenAPI)
-export type TankPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
-export type TankMode = 'deathmatch' | 'lastman';
-export type BotLevel = 'easy' | 'medium' | 'hard';
-export type PickupType = 'shield' | 'triple' | 'rocket' | 'turbo' | 'ghost' | 'homing' | 'mine' | 'heart';
-export type Biome = 'city' | 'forest' | 'desert' | 'winter' | 'swamp';
-export type Team = 0 | 1;
-export type TankPlayer = {
- login: string,
- color: number,
- team?: Team,
- x: number,
- y: number,
- angle: number,
- turret: number,
- alive: boolean,
- kills: number,
- deaths: number,
- score: number,
- lives?: number,
- shield: boolean,
- triple: boolean,
- turbo: boolean,
- ghost: boolean,
- homing: boolean,
- rockets: number,
- mines: number,
- protected: boolean,
- shots: number,
- respawnIn?: number,
- bot?: BotLevel,
- place?: number,
-};
-export type TankState = {
- phase: TankPhase,
- host?: string,
- mode: TankMode,
- teams: boolean,
- hardcore: boolean,
- bounce: boolean,
- mapId: string,
- biome: Biome,
- maps: { id: string, name: string, biome: Biome }[],
- tiles: string[],
- players: TankPlayer[],
- bullets: { x: number, y: number, color: number, team?: Team, angle: number, rocket: boolean }[],
- pickups: { x: number, y: number, type: PickupType }[],
- mines: { x: number, y: number, owner: string, team?: Team, color: number, armed: boolean }[],
- wrecks: { x: number, y: number, angle: number, turret: number, color: number, team?: Team, age: number }[],
- explosions: { x: number, y: number, r: number, age: number }[],
- teamScores?: [number, number],
- winnerTeam?: number,
- countdown?: number,
- timeLeftMs: number,
- width: number,
- height: number,
- tile: number,
- minPlayers: number,
- maxPlayers: number,
-};
-/** Záblesk výstřelu — klient ho zakládá podle změny počítadla výstřelů. */
-export type MuzzleFlash = { login: string, start: number };
-
-// Konstanty musí odpovídat serveru
-const TANK_RADIUS = 14;
-const BULLET_RADIUS = 4;
-const EXPLOSION_MS = 600;
-const WRECK_MS = 8000;
-const REVEAL_DISTANCE = 70;
-const LIGHT_RADIUS = 130;
-const SPOTLIGHT_RANGE = 300;
-const SPOTLIGHT_HALF_ANGLE = 0.45;
-export const FLASH_MS = 110;
-
-export const COLORS = ['#4ade80', '#60a5fa', '#f472b6', '#facc15', '#a78bfa', '#fb923c'];
-/** Týmy mají vlastní paletu (teplé × studené barvy). */
-export const TEAM_COLORS: [string[], string[]] = [['#f87171', '#fb923c', '#fbbf24'], ['#60a5fa', '#22d3ee', '#a78bfa']];
-export const TEAM_NAMES = ['Červení', 'Modří'];
-
-export const PICKUPS: Record = {
- shield: { icon: '🛡️', label: 'štít — vydrží jeden zásah' },
- triple: { icon: '🔱', label: 'trojitá střela — tři střely do vějíře' },
- rocket: { icon: '🚀', label: 'rakety — výbuch zasáhne i okolí' },
- turbo: { icon: '💨', label: 'turbo — rychlejší jízda' },
- ghost: { icon: '👻', label: 'neviditelnost — soupeř vás uvidí jen zblízka' },
- homing: { icon: '🧲', label: 'naváděné střely — stáčí se k soupeři' },
- mine: { icon: '💣', label: 'miny — položíte E / pravým tlačítkem' },
- heart: { icon: '❤️', label: 'život navíc (jen na přežití)' },
-};
-
-export const BIOMES: Record = {
- city: { icon: '🏙️' },
- forest: { icon: '🌲' },
- desert: { icon: '🏜️' },
- winter: { icon: '❄️' },
- swamp: { icon: '🐸' },
-};
-
-export const tankColor = (o: { color: number, team?: Team }) =>
- o.team !== undefined ? TEAM_COLORS[o.team][o.color % TEAM_COLORS[o.team].length] : COLORS[o.color % COLORS.length];
-
-/** Deterministická „náhoda" podle polohy dlaždice — dekorace se nemění mezi snímky. */
-const hash = (col: number, row: number, k: number) => {
- const v = Math.sin(col * 12.9898 + row * 78.233 + k * 37.719) * 43758.5453;
- return v - Math.floor(v);
-};
-
-type BiomeStyle = {
- ground: [string, string],
- speck: string,
- water: string,
- mud: string,
-};
-
-const STYLES: Record = {
- city: { ground: ['#1f2937', '#243041'], speck: 'rgba(255,255,255,0.03)', water: '#1e3a5f', mud: '#4b4036' },
- forest: { ground: ['#2d4a26', '#31502a'], speck: 'rgba(0,0,0,0.12)', water: '#2563eb', mud: '#5b4630' },
- desert: { ground: ['#d8b46c', '#d2ad64'], speck: 'rgba(120,80,30,0.18)', water: '#0e7490', mud: '#b8934f' },
- winter: { ground: ['#e2e8f0', '#dbe2ec'], speck: 'rgba(100,116,139,0.15)', water: '#1e3a8a', mud: '#f8fafc' },
- swamp: { ground: ['#38432a', '#3c482d'], speck: 'rgba(0,0,0,0.15)', water: '#1f3d36', mud: '#4a3b26' },
-};
-
-// --- Statická vrstva mapy — překresluje se jen při změně dlaždic ---
-
-let layerCache: { key: string, ground: HTMLCanvasElement } | undefined;
-
-function mapLayer(state: TankState) {
- const key = `${state.biome}|${state.tiles.join('')}`;
- if (layerCache?.key === key) return layerCache.ground;
- const ground = document.createElement('canvas');
- ground.width = state.width;
- ground.height = state.height;
- drawGround(ground.getContext('2d')!, state);
- layerCache = { key, ground };
- return ground;
-}
-
-function drawGround(ctx: CanvasRenderingContext2D, state: TankState) {
- const t = state.tile;
- const style = STYLES[state.biome];
- state.tiles.forEach((row, r) => [...row].forEach((_, col) => {
- const x = col * t;
- const y = r * t;
- ctx.fillStyle = style.ground[(col + r) % 2];
- ctx.fillRect(x, y, t, t);
- ctx.fillStyle = style.speck;
- for (let k = 0; k < 4; k++) {
- ctx.fillRect(x + hash(col, r, k) * (t - 4), y + hash(col, r, k + 9) * (t - 4), 3, 3);
- }
- }));
- // Nejdřív plochy (voda, bláto, porost), pak překážky přes ně — stromy a skály přesahují políčko
- state.tiles.forEach((row, r) => [...row].forEach((c, col) => {
- if (c === '~') drawWater(ctx, state.biome, col, r, t);
- else if (c === ':') drawMud(ctx, state.biome, col, r, t);
- else if (c === '%') drawGrowth(ctx, state.biome, col, r, t);
- }));
- state.tiles.forEach((row, r) => [...row].forEach((c, col) => {
- if (c === '#') drawWall(ctx, state.biome, col, r, t);
- else if (c === '+') drawCrate(ctx, state.biome, col, r, t);
- }));
-}
-
-function drawWater(ctx: CanvasRenderingContext2D, biome: Biome, col: number, row: number, t: number) {
- const x = col * t;
- const y = row * t;
- ctx.fillStyle = STYLES[biome].water;
- ctx.fillRect(x, y, t, t);
- if (biome === 'winter') {
- // Díra v ledu: světlý okraj
- ctx.strokeStyle = 'rgba(255,255,255,0.7)';
- ctx.lineWidth = 3;
- ctx.strokeRect(x + 1.5, y + 1.5, t - 3, t - 3);
- }
- // Vlnky
- ctx.strokeStyle = 'rgba(255,255,255,0.18)';
- ctx.lineWidth = 1.5;
- for (let k = 0; k < 2; k++) {
- const wx = x + 6 + hash(col, row, k) * (t - 18);
- const wy = y + 8 + hash(col, row, k + 4) * (t - 16);
- ctx.beginPath();
- ctx.moveTo(wx, wy);
- ctx.quadraticCurveTo(wx + 3, wy - 3, wx + 6, wy);
- ctx.quadraticCurveTo(wx + 9, wy + 3, wx + 12, wy);
- ctx.stroke();
- }
- if (biome === 'swamp' && hash(col, row, 7) > 0.5) {
- // Lekníny
- ctx.fillStyle = '#4d7c0f';
- ctx.beginPath();
- ctx.arc(x + 10 + hash(col, row, 8) * 20, y + 10 + hash(col, row, 9) * 20, 5, 0.4, Math.PI * 2);
- ctx.lineTo(x + 10 + hash(col, row, 8) * 20, y + 10 + hash(col, row, 9) * 20);
- ctx.fill();
- }
-}
-
-function drawMud(ctx: CanvasRenderingContext2D, biome: Biome, col: number, row: number, t: number) {
- const x = col * t;
- const y = row * t;
- ctx.fillStyle = STYLES[biome].mud;
- ctx.fillRect(x, y, t, t);
- if (biome === 'winter') {
- // Závěje: stínované oblouky
- ctx.strokeStyle = 'rgba(148,163,184,0.5)';
- ctx.lineWidth = 2;
- for (let k = 0; k < 2; k++) {
- ctx.beginPath();
- ctx.arc(x + 8 + hash(col, row, k) * 24, y + 14 + k * 14, 8, Math.PI * 1.1, Math.PI * 1.9);
- ctx.stroke();
- }
- return;
- }
- if (biome === 'desert') {
- // Pohyblivý písek: víry
- ctx.strokeStyle = 'rgba(120,80,30,0.35)';
- ctx.lineWidth = 1.5;
- ctx.beginPath();
- ctx.arc(x + t / 2, y + t / 2, 10, 0, Math.PI * 1.5);
- ctx.stroke();
- ctx.beginPath();
- ctx.arc(x + t / 2, y + t / 2, 4, Math.PI, Math.PI * 2.5);
- ctx.stroke();
- return;
- }
- // Bláto: tmavší louže
- ctx.fillStyle = 'rgba(0,0,0,0.2)';
- for (let k = 0; k < 3; k++) {
- ctx.beginPath();
- ctx.ellipse(x + 6 + hash(col, row, k) * 28, y + 6 + hash(col, row, k + 3) * 28, 6, 4, 0, 0, Math.PI * 2);
- ctx.fill();
- }
-}
-
-function drawWall(ctx: CanvasRenderingContext2D, biome: Biome, col: number, row: number, t: number) {
- const x = col * t;
- const y = row * t;
- const cx = x + t / 2;
- const cy = y + t / 2;
- switch (biome) {
- case 'city':
- ctx.fillStyle = '#4b5563';
- ctx.fillRect(x, y, t, t);
- ctx.fillStyle = '#6b7280';
- ctx.fillRect(x, y, t, 4);
- ctx.strokeStyle = 'rgba(0,0,0,0.25)';
- ctx.lineWidth = 1;
- ctx.strokeRect(x + 0.5, y + 0.5, t - 1, t - 1);
- return;
- case 'forest': {
- // Strom: stín, koruna ve vrstvách a odlesk
- ctx.fillStyle = 'rgba(0,0,0,0.3)';
- ctx.beginPath();
- ctx.arc(cx + 3, cy + 4, t * 0.5, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#14532d';
- ctx.beginPath();
- ctx.arc(cx, cy, t * 0.52, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#166534';
- for (let k = 0; k < 4; k++) {
- const a = hash(col, row, k) * Math.PI * 2;
- ctx.beginPath();
- ctx.arc(cx + Math.cos(a) * 8, cy + Math.sin(a) * 8, t * 0.26, 0, Math.PI * 2);
- ctx.fill();
- }
- ctx.fillStyle = 'rgba(134,239,172,0.25)';
- ctx.beginPath();
- ctx.arc(cx - 6, cy - 6, t * 0.18, 0, Math.PI * 2);
- ctx.fill();
- return;
- }
- case 'swamp': {
- // Pahýl: kořeny, letokruhy a mech
- ctx.fillStyle = '#2b2118';
- for (let k = 0; k < 5; k++) {
- const a = k / 5 * Math.PI * 2 + hash(col, row, k);
- ctx.beginPath();
- ctx.ellipse(cx + Math.cos(a) * 13, cy + Math.sin(a) * 13, 7, 4, a, 0, Math.PI * 2);
- ctx.fill();
- }
- ctx.fillStyle = '#5b4632';
- ctx.beginPath();
- ctx.arc(cx, cy, 14, 0, Math.PI * 2);
- ctx.fill();
- ctx.strokeStyle = '#3b2d20';
- ctx.lineWidth = 1.5;
- for (const r of [10, 6, 2.5]) {
- ctx.beginPath();
- ctx.arc(cx, cy, r, 0, Math.PI * 2);
- ctx.stroke();
- }
- ctx.fillStyle = 'rgba(101,163,13,0.6)';
- ctx.beginPath();
- ctx.arc(cx - 8, cy + 7, 5, 0, Math.PI * 2);
- ctx.fill();
- return;
- }
- default: {
- // Skála (poušť pískovec, zima žula se sněhem)
- const winter = biome === 'winter';
- ctx.fillStyle = 'rgba(0,0,0,0.25)';
- ctx.beginPath();
- ctx.roundRect(x + 3, y + 5, t - 2, t - 3, 10);
- ctx.fill();
- ctx.fillStyle = winter ? '#64748b' : '#a87b45';
- ctx.beginPath();
- ctx.roundRect(x + 1, y + 1, t - 2, t - 2, 10);
- ctx.fill();
- ctx.fillStyle = winter ? '#475569' : '#7c5a32';
- ctx.beginPath();
- ctx.roundRect(x + 1, y + t * 0.55, t - 2, t * 0.45 - 1, [0, 0, 10, 10]);
- ctx.fill();
- if (winter) {
- ctx.fillStyle = '#f8fafc';
- ctx.beginPath();
- ctx.roundRect(x + 2, y + 1, t - 4, t * 0.35, [10, 10, 6, 6]);
- ctx.fill();
- } else {
- ctx.strokeStyle = 'rgba(255,240,200,0.35)';
- ctx.lineWidth = 1.5;
- ctx.beginPath();
- ctx.moveTo(x + 8, y + 12);
- ctx.lineTo(x + t - 10, y + 10);
- ctx.moveTo(x + 6, y + 20);
- ctx.lineTo(x + t - 14, y + 19);
- ctx.stroke();
- }
- }
- }
-}
-
-function drawCrate(ctx: CanvasRenderingContext2D, biome: Biome, col: number, row: number, t: number) {
- const x = col * t;
- const y = row * t;
- if (biome === 'forest' || biome === 'swamp') {
- // Hromada klád
- for (const [lx, ly] of [[13, 25], [27, 25], [20, 13]]) {
- ctx.fillStyle = '#78350f';
- ctx.beginPath();
- ctx.arc(x + lx, y + ly, 8, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#d6a76b';
- ctx.beginPath();
- ctx.arc(x + lx, y + ly, 5.5, 0, Math.PI * 2);
- ctx.fill();
- ctx.strokeStyle = '#92400e';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.arc(x + lx, y + ly, 2.5, 0, Math.PI * 2);
- ctx.stroke();
- }
- return;
- }
- ctx.fillStyle = '#a16207';
- ctx.fillRect(x + 2, y + 2, t - 4, t - 4);
- ctx.strokeStyle = '#713f12';
- ctx.lineWidth = 3;
- ctx.strokeRect(x + 4, y + 4, t - 8, t - 8);
- ctx.beginPath();
- ctx.moveTo(x + 4, y + 4);
- ctx.lineTo(x + t - 4, y + t - 4);
- ctx.moveTo(x + t - 4, y + 4);
- ctx.lineTo(x + 4, y + t - 4);
- ctx.stroke();
- if (biome === 'winter') {
- ctx.fillStyle = '#f8fafc';
- ctx.beginPath();
- ctx.roundRect(x + 2, y + 1, t - 4, 7, 3);
- ctx.fill();
- }
-}
-
-/**
- * Porost (zpomaluje): nízká vegetace v úrovni země, přes kterou se jede — kapradí, suchá tráva,
- * keříky ve sněhu nebo rákos. Kreslí se pod tanky a je řídký, aby nepřipomínal stromy.
- */
-function drawGrowth(ctx: CanvasRenderingContext2D, biome: Biome, col: number, row: number, t: number) {
- const x = col * t;
- const y = row * t;
- const style = GROWTH[biome];
- // Lehce odlišený podklad, ať je vidět, kde porost začíná
- ctx.fillStyle = style.patch;
- ctx.beginPath();
- ctx.roundRect(x + 1, y + 1, t - 2, t - 2, 10);
- ctx.fill();
- for (let k = 0; k < 6; k++) {
- const bx = x + 6 + hash(col, row, k) * (t - 12);
- const by = y + 10 + hash(col, row, k + 11) * (t - 14);
- // Trs stébel / lístků vějířovitě nahoru
- ctx.strokeStyle = k % 2 ? style.blade : style.blade2;
- ctx.lineWidth = biome === 'winter' ? 1.5 : 1.8;
- ctx.lineCap = 'round';
- const blades = biome === 'swamp' ? 3 : 5;
- for (let b = 0; b < blades; b++) {
- const a = -Math.PI / 2 + (b / (blades - 1) - 0.5) * (biome === 'swamp' ? 0.5 : 1.6);
- const len = (biome === 'swamp' ? 13 : 8) + hash(col, row, k * 7 + b) * 4;
- ctx.beginPath();
- ctx.moveTo(bx, by);
- ctx.quadraticCurveTo(bx + Math.cos(a) * len * 0.5, by + Math.sin(a) * len * 0.6,
- bx + Math.cos(a) * len, by + Math.sin(a) * len);
- ctx.stroke();
- }
- if (biome === 'swamp' && k % 2 === 0) {
- // Palice orobince
- ctx.fillStyle = '#6b3f1d';
- ctx.beginPath();
- ctx.ellipse(bx, by - 13, 2, 4, 0, 0, Math.PI * 2);
- ctx.fill();
- }
- if (biome === 'winter') {
- ctx.fillStyle = '#fff';
- ctx.beginPath();
- ctx.arc(bx + 2, by - 7, 1.8, 0, Math.PI * 2);
- ctx.fill();
- }
- }
- ctx.lineCap = 'butt';
-}
-
-const GROWTH: Record = {
- city: { patch: 'rgba(74,222,128,0.08)', blade: '#4d7c0f', blade2: '#65a30d' },
- forest: { patch: 'rgba(132,204,22,0.12)', blade: '#84cc16', blade2: '#65a30d' },
- desert: { patch: 'rgba(161,98,7,0.12)', blade: '#a3842a', blade2: '#8a6d1f' },
- winter: { patch: 'rgba(120,113,108,0.1)', blade: '#78716c', blade2: '#57534e' },
- swamp: { patch: 'rgba(163,230,53,0.1)', blade: '#7c9a2a', blade2: '#a3b33a' },
-};
-
-// --- Snímek ---
-
-/**
- * Vykreslí celý snímek. `me` je přihlášený hráč — sebe a spoluhráče s neviditelností vidí
- * průsvitně, neviditelné soupeře jen zblízka. V hardcore vidí soupeře, střely a bonusy jen ve světle
- * svém a spoluhráčů; výbuchy a záblesky výstřelů prosvítí i tmou. Divák a vyřazený hráč vidí všechno.
- */
-export function drawTanks(canvas: HTMLCanvasElement, state: TankState, flashes: MuzzleFlash[], me?: string) {
- const ctx = canvas.getContext('2d');
- if (!ctx) return;
- const scale = canvas.width / state.width;
- const now = performance.now();
- ctx.setTransform(scale, 0, 0, scale, 0, 0);
- ctx.drawImage(mapLayer(state), 0, 0);
-
- const self = state.players.find(p => p.login === me);
- const friendly = (o: { login?: string, owner?: string, team?: Team }) =>
- !self || (o.login ?? o.owner) === me || (state.teams && o.team === self.team);
- const near = (o: { x: number, y: number }) => !!self?.alive && Math.hypot(o.x - self.x, o.y - self.y) < REVEAL_DISTANCE;
- const perceivable = (p: TankPlayer) => p.alive && (friendly(p) || !p.ghost || near(p));
-
- // Tma: hráč, který ještě hraje (i čekající na návrat), vidí jen světlo svoje a spoluhráčů
- const dark = state.hardcore && !!self && (self.alive || self.respawnIn !== undefined);
- const lights = dark
- ? state.players.filter(p => p.alive && friendly(p)).map(p => castLight(state, p))
- : [];
- const lit = (o: { x: number, y: number }, margin = 0) => !dark || inLight(lights, o.x, o.y, margin);
-
- for (const w of state.wrecks) if (lit(w, 20)) drawWreck(ctx, w, now);
- for (const m of state.mines) if (friendly(m) || lit(m)) drawMine(ctx, m, friendly(m), now);
- for (const pk of state.pickups) if (lit(pk, 14)) drawPickup(ctx, pk, now);
-
- const tanks = state.players.filter(p => perceivable(p) && (friendly(p) || lit(p, TANK_RADIUS)));
- for (const p of tanks) {
- ctx.globalAlpha = p.ghost ? 0.45 : 1;
- drawTank(ctx, p, p.login === me, now);
- ctx.globalAlpha = 1;
- }
- for (const b of state.bullets) if (lit(b, BULLET_RADIUS)) drawBullet(ctx, b, now);
-
- if (dark) drawDarkness(ctx, state, lights);
-
- for (const f of flashes) {
- const p = state.players.find(x => x.login === f.login);
- if (p && perceivable(p)) drawFlash(ctx, p, (now - f.start) / FLASH_MS);
- }
- for (const e of state.explosions) drawExplosion(ctx, e);
- for (const p of tanks) drawLabel(ctx, p, p.login === me);
- // Při startu a po návratu do hry ukazuje na vlastní tank šipka s pulzujícím kruhem
- if (self?.alive && (state.phase === 'countdown' || self.protected)) drawSelfMarker(ctx, self, now);
- ctx.setTransform(1, 0, 0, 1, 0, 0);
-}
-
-function drawSelfMarker(ctx: CanvasRenderingContext2D, p: TankPlayer, now: number) {
- const pulse = (now % 900) / 900;
- ctx.strokeStyle = `rgba(250, 204, 21, ${0.9 * (1 - pulse)})`;
- ctx.lineWidth = 3;
- ctx.beginPath();
- ctx.arc(p.x, p.y, TANK_RADIUS + 10 + pulse * 40, 0, Math.PI * 2);
- ctx.stroke();
-
- // Šipka nad jmenovkou; u horního okraje hřiště pod tankem
- const below = p.y < 70;
- const dir = below ? -1 : 1;
- const bob = Math.sin(now / 150) * 3;
- const tipY = below ? p.y + TANK_RADIUS + 10 + bob : p.y - TANK_RADIUS - 20 + bob;
- ctx.beginPath();
- ctx.moveTo(p.x, tipY);
- ctx.lineTo(p.x - 9, tipY - dir * 14);
- ctx.lineTo(p.x - 4, tipY - dir * 14);
- ctx.lineTo(p.x - 4, tipY - dir * 24);
- ctx.lineTo(p.x + 4, tipY - dir * 24);
- ctx.lineTo(p.x + 4, tipY - dir * 14);
- ctx.lineTo(p.x + 9, tipY - dir * 14);
- ctx.closePath();
- ctx.lineWidth = 3;
- ctx.strokeStyle = '#0b0f19';
- ctx.stroke();
- ctx.fillStyle = '#facc15';
- ctx.fill();
-
- ctx.font = 'bold 13px sans-serif';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- const textY = tipY - dir * 33;
- ctx.lineWidth = 3;
- ctx.strokeText('VY', p.x, textY);
- ctx.fillText('VY', p.x, textY);
-}
-
-// --- Hardcore: světlo a tma ---
-
-/** Paprsek světla ze středu tanku do každého směru; délka končí na zdi či bedně. */
-type Light = { x: number, y: number, lengths: Float32Array };
-
-const LIGHT_RAYS = 240;
-const LIGHT_STEP = 4;
-/** Šířka přechodu mezi kruhem a kuželem reflektoru (rad), ať okraj kužele není ostrý zub. */
-const SPOTLIGHT_EDGE = 0.12;
-
-const wrapAngle = (a: number) => {
- const d = a % (Math.PI * 2);
- return d < 0 ? d + Math.PI * 2 : d;
-};
-
-function castLight(state: TankState, src: TankPlayer): Light {
- const t = state.tile;
- const blocks = (x: number, y: number) => {
- if (x < 0 || y < 0 || x >= state.width || y >= state.height) return true;
- const c = state.tiles[Math.floor(y / t)]?.[Math.floor(x / t)];
- return c === '#' || c === '+';
- };
- const lengths = new Float32Array(LIGHT_RAYS);
- for (let i = 0; i < LIGHT_RAYS; i++) {
- const a = i / LIGHT_RAYS * Math.PI * 2;
- const off = Math.abs(wrapAngle(a - src.turret + Math.PI) - Math.PI);
- const reach = off <= SPOTLIGHT_HALF_ANGLE
- ? SPOTLIGHT_RANGE
- : off <= SPOTLIGHT_HALF_ANGLE + SPOTLIGHT_EDGE
- ? LIGHT_RADIUS + (SPOTLIGHT_RANGE - LIGHT_RADIUS) * (1 - (off - SPOTLIGHT_HALF_ANGLE) / SPOTLIGHT_EDGE)
- : LIGHT_RADIUS;
- const dx = Math.cos(a);
- const dy = Math.sin(a);
- let d = 0;
- while (d < reach && !blocks(src.x + dx * (d + LIGHT_STEP), src.y + dy * (d + LIGHT_STEP))) d += LIGHT_STEP;
- // Světlo dopadne i na stěnu překážky, ne jen před ni
- lengths[i] = d < reach ? d + LIGHT_STEP * 2 : reach;
- }
- return { x: src.x, y: src.y, lengths };
-}
-
-/** Zda je bod (s tolerancí poloměru objektu) v některém světle. */
-function inLight(lights: Light[], x: number, y: number, margin: number) {
- return lights.some(l => {
- const d = Math.hypot(x - l.x, y - l.y);
- if (d <= margin) return true;
- const i = Math.round(wrapAngle(Math.atan2(y - l.y, x - l.x)) / (Math.PI * 2) * LIGHT_RAYS) % LIGHT_RAYS;
- return d - margin <= l.lengths[i];
- });
-}
-
-let darknessCanvas: HTMLCanvasElement | undefined;
-
-/** Tma přes celou plochu s vyříznutým světlem (rozmazané okraje, slábnoucí do dálky). */
-function drawDarkness(ctx: CanvasRenderingContext2D, state: TankState, lights: Light[]) {
- if (!darknessCanvas) darknessCanvas = document.createElement('canvas');
- const dc = darknessCanvas;
- if (dc.width !== state.width || dc.height !== state.height) {
- dc.width = state.width;
- dc.height = state.height;
- }
- const d = dc.getContext('2d');
- if (!d) return;
- d.globalCompositeOperation = 'source-over';
- d.clearRect(0, 0, dc.width, dc.height);
- // Mapa zůstane matně vidět, aby se dalo orientovat
- d.fillStyle = 'rgba(2, 4, 10, 0.84)';
- d.fillRect(0, 0, dc.width, dc.height);
- d.globalCompositeOperation = 'destination-out';
- d.filter = 'blur(8px)';
- for (const l of lights) {
- const fade = d.createRadialGradient(l.x, l.y, 0, l.x, l.y, SPOTLIGHT_RANGE);
- fade.addColorStop(0, 'rgba(0,0,0,1)');
- fade.addColorStop(LIGHT_RADIUS / SPOTLIGHT_RANGE * 0.8, 'rgba(0,0,0,1)');
- fade.addColorStop(1, 'rgba(0,0,0,0.55)');
- d.fillStyle = fade;
- d.beginPath();
- for (let i = 0; i < LIGHT_RAYS; i++) {
- const a = i / LIGHT_RAYS * Math.PI * 2;
- d.lineTo(l.x + Math.cos(a) * l.lengths[i], l.y + Math.sin(a) * l.lengths[i]);
- }
- d.closePath();
- d.fill();
- }
- d.filter = 'none';
- d.globalCompositeOperation = 'source-over';
- ctx.drawImage(dc, 0, 0);
-}
-
-function drawTank(ctx: CanvasRenderingContext2D, p: TankPlayer, mine: boolean, now: number) {
- const color = tankColor(p);
- const base = ctx.globalAlpha;
- // Vlastní tank má pod sebou výrazný otáčející se kruh, ať se v hemžení najde (tmavý lem kvůli světlým mapám)
- if (mine) {
- ctx.fillStyle = 'rgba(250, 204, 21, 0.18)';
- ctx.beginPath();
- ctx.arc(p.x, p.y, TANK_RADIUS + 7, 0, Math.PI * 2);
- ctx.fill();
- ctx.setLineDash([7, 5]);
- ctx.lineDashOffset = -now / 40;
- ctx.strokeStyle = 'rgba(11, 15, 25, 0.7)';
- ctx.lineWidth = 4.5;
- ctx.stroke();
- ctx.strokeStyle = '#facc15';
- ctx.lineWidth = 2.5;
- ctx.stroke();
- ctx.setLineDash([]);
- ctx.lineDashOffset = 0;
- }
- // Ochrana po zrození — tank bliká
- if (p.protected && Math.floor(now / 120) % 2 === 0) ctx.globalAlpha = base * 0.35;
- // Turbo: proužky za tankem
- if (p.turbo) {
- ctx.strokeStyle = 'rgba(255,255,255,0.5)';
- ctx.lineWidth = 2;
- for (const s of [-8, 0, 8]) {
- const len = 10 + Math.sin(now / 60 + s) * 4;
- const bx = p.x - Math.cos(p.angle) * 18 + -Math.sin(p.angle) * s;
- const by = p.y - Math.sin(p.angle) * 18 + Math.cos(p.angle) * s;
- ctx.beginPath();
- ctx.moveTo(bx, by);
- ctx.lineTo(bx - Math.cos(p.angle) * len, by - Math.sin(p.angle) * len);
- ctx.stroke();
- }
- }
- drawHull(ctx, p.x, p.y, p.angle, color, (p.x + p.y) * 0.7);
- drawTurret(ctx, p.x, p.y, p.turret, color, p.triple ? 3 : 1, p.homing);
- if (p.shield) {
- ctx.strokeStyle = '#38bdf8';
- ctx.lineWidth = 2.5;
- ctx.beginPath();
- ctx.arc(p.x, p.y, TANK_RADIUS + 6, 0, Math.PI * 2);
- ctx.stroke();
- ctx.fillStyle = 'rgba(56,189,248,0.12)';
- ctx.fill();
- }
- ctx.globalAlpha = base;
-}
-
-function drawHull(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, color: string, travel: number) {
- ctx.save();
- ctx.translate(x, y);
- ctx.rotate(angle);
- // Stín
- ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
- ctx.beginPath();
- ctx.roundRect(-14, -13, 32, 30, 6);
- ctx.fill();
- // Pásy s články, které se posouvají s jízdou
- const tread = travel % 5;
- for (const ty of [-15, 8]) {
- ctx.fillStyle = '#111827';
- ctx.beginPath();
- ctx.roundRect(-16, ty, 32, 7, 3);
- ctx.fill();
- ctx.strokeStyle = '#4b5563';
- ctx.lineWidth = 1.5;
- ctx.beginPath();
- for (let tx = -15 + tread; tx < 15; tx += 5) {
- ctx.moveTo(tx, ty + 1);
- ctx.lineTo(tx, ty + 6);
- }
- ctx.stroke();
- }
- // Korba se stínováním a čelním pancířem
- ctx.fillStyle = color;
- ctx.beginPath();
- ctx.roundRect(-13, -9, 26, 18, 4);
- ctx.fill();
- const light = ctx.createLinearGradient(0, -9, 0, 9);
- light.addColorStop(0, 'rgba(255, 255, 255, 0.3)');
- light.addColorStop(0.5, 'rgba(255, 255, 255, 0)');
- light.addColorStop(1, 'rgba(0, 0, 0, 0.3)');
- ctx.fillStyle = light;
- ctx.fill();
- ctx.strokeStyle = 'rgba(0, 0, 0, 0.55)';
- ctx.lineWidth = 1.5;
- ctx.stroke();
- ctx.fillStyle = 'rgba(0, 0, 0, 0.25)';
- ctx.fillRect(8, -8, 3, 16);
- ctx.restore();
-}
-
-function drawTurret(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, color: string, barrels: number, homing: boolean) {
- ctx.save();
- ctx.translate(x, y);
- ctx.rotate(angle);
- for (const a of barrels === 3 ? [-0.22, 0, 0.22] : [0]) {
- ctx.save();
- ctx.rotate(a);
- ctx.fillStyle = '#d1d5db';
- ctx.strokeStyle = '#1f2937';
- ctx.lineWidth = 1;
- ctx.beginPath();
- ctx.rect(2, -2.5, 17, 5);
- ctx.fill();
- ctx.stroke();
- ctx.fillStyle = homing ? '#e879f9' : '#6b7280';
- ctx.beginPath();
- ctx.rect(18, -3.5, 4, 7);
- ctx.fill();
- ctx.stroke();
- ctx.restore();
- }
- ctx.fillStyle = color;
- ctx.beginPath();
- ctx.roundRect(-8, -7, 14, 14, 5);
- ctx.fill();
- const glow = ctx.createRadialGradient(-3, -3, 1, -1, 0, 10);
- glow.addColorStop(0, 'rgba(255, 255, 255, 0.45)');
- glow.addColorStop(1, 'rgba(0, 0, 0, 0.2)');
- ctx.fillStyle = glow;
- ctx.fill();
- ctx.strokeStyle = 'rgba(0, 0, 0, 0.6)';
- ctx.lineWidth = 1.5;
- ctx.stroke();
- ctx.fillStyle = 'rgba(0, 0, 0, 0.35)';
- ctx.beginPath();
- ctx.arc(-3, 0, 2.5, 0, Math.PI * 2);
- ctx.fill();
- ctx.restore();
-}
-
-/** Bonus: záře, tmavý podklad se zlatým pulzujícím kruhem a větší ikona, která lehce poskakuje. */
-function drawPickup(ctx: CanvasRenderingContext2D, pk: TankState['pickups'][number], now: number) {
- const pulse = (Math.sin(now / 180 + pk.x) + 1) / 2;
- const bob = Math.sin(now / 260 + pk.y) * 2;
- const glow = ctx.createRadialGradient(pk.x, pk.y, 6, pk.x, pk.y, 28 + pulse * 6);
- glow.addColorStop(0, 'rgba(253, 224, 71, 0.55)');
- glow.addColorStop(1, 'rgba(253, 224, 71, 0)');
- ctx.fillStyle = glow;
- ctx.beginPath();
- ctx.arc(pk.x, pk.y, 34, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = 'rgba(15, 23, 42, 0.75)';
- ctx.beginPath();
- ctx.arc(pk.x, pk.y + bob, 16, 0, Math.PI * 2);
- ctx.fill();
- ctx.strokeStyle = `rgba(250, 204, 21, ${0.7 + pulse * 0.3})`;
- ctx.lineWidth = 2.5;
- ctx.stroke();
- // Otáčející se čárkovaný prstenec, ať bonus upoutá pozornost
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
- ctx.lineWidth = 1.5;
- ctx.setLineDash([4, 6]);
- ctx.lineDashOffset = -now / 40;
- ctx.beginPath();
- ctx.arc(pk.x, pk.y + bob, 20 + pulse * 2, 0, Math.PI * 2);
- ctx.stroke();
- ctx.setLineDash([]);
- ctx.lineDashOffset = 0;
- ctx.font = '20px sans-serif';
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.fillText(PICKUPS[pk.type].icon, pk.x, pk.y + bob + 1);
-}
-
-function drawLabel(ctx: CanvasRenderingContext2D, p: TankPlayer, mine: boolean) {
- const y = p.y - TANK_RADIUS - 10;
- ctx.font = `${mine ? 'bold ' : ''}11px sans-serif`;
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.lineWidth = 3;
- ctx.strokeStyle = '#0b0f19';
- ctx.strokeText(p.login, p.x, y);
- ctx.fillStyle = mine ? '#fff' : tankColor(p);
- ctx.fillText(p.login, p.x, y);
-}
-
-function drawBullet(ctx: CanvasRenderingContext2D, b: TankState['bullets'][number], now: number) {
- const color = tankColor(b);
- if (!b.rocket) {
- ctx.fillStyle = color;
- ctx.beginPath();
- ctx.arc(b.x, b.y, BULLET_RADIUS, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#fff';
- ctx.beginPath();
- ctx.arc(b.x, b.y, BULLET_RADIUS / 2, 0, Math.PI * 2);
- ctx.fill();
- return;
- }
- ctx.save();
- ctx.translate(b.x, b.y);
- ctx.rotate(b.angle);
- // Plamen z trysky
- const flame = 8 + Math.sin(now / 30) * 3;
- ctx.fillStyle = '#f97316';
- ctx.beginPath();
- ctx.moveTo(-6, -3);
- ctx.lineTo(-6 - flame, 0);
- ctx.lineTo(-6, 3);
- ctx.fill();
- ctx.fillStyle = '#fde047';
- ctx.beginPath();
- ctx.moveTo(-6, -1.5);
- ctx.lineTo(-6 - flame * 0.5, 0);
- ctx.lineTo(-6, 1.5);
- ctx.fill();
- ctx.fillStyle = '#e5e7eb';
- ctx.fillRect(-6, -3, 10, 6);
- ctx.fillStyle = color;
- ctx.beginPath();
- ctx.moveTo(4, -3);
- ctx.lineTo(9, 0);
- ctx.lineTo(4, 3);
- ctx.fill();
- ctx.fillRect(-7, -4.5, 3, 9);
- ctx.restore();
-}
-
-function drawMine(ctx: CanvasRenderingContext2D, m: TankState['mines'][number], friendly: boolean, now: number) {
- // Cizí miny jsou jen málo vidět
- ctx.globalAlpha = friendly ? 1 : 0.3;
- ctx.fillStyle = '#1f2937';
- ctx.strokeStyle = tankColor(m);
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.arc(m.x, m.y, 7, 0, Math.PI * 2);
- ctx.fill();
- ctx.stroke();
- ctx.fillStyle = m.armed && Math.floor(now / 300) % 2 === 0 ? '#ef4444' : '#6b7280';
- ctx.beginPath();
- ctx.arc(m.x, m.y, 2.5, 0, Math.PI * 2);
- ctx.fill();
- ctx.globalAlpha = 1;
-}
-
-/** Vrak: ohořelá korba s věží, plameny slábnou a stoupá kouř. */
-function drawWreck(ctx: CanvasRenderingContext2D, w: TankState['wrecks'][number], now: number) {
- const fire = Math.max(0, 1 - w.age / WRECK_MS);
- ctx.save();
- ctx.translate(w.x, w.y);
- ctx.rotate(w.angle);
- ctx.fillStyle = 'rgba(0,0,0,0.35)';
- ctx.beginPath();
- ctx.ellipse(1, 2, 20, 17, 0, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#1c1917';
- ctx.fillRect(-16, -15, 32, 7);
- ctx.fillRect(-16, 8, 32, 7);
- ctx.fillStyle = '#292524';
- ctx.beginPath();
- ctx.roundRect(-13, -9, 26, 18, 4);
- ctx.fill();
- ctx.globalAlpha = 0.3;
- ctx.fillStyle = tankColor(w);
- ctx.fill();
- ctx.globalAlpha = 1;
- ctx.rotate(w.turret - w.angle + 0.5);
- ctx.fillStyle = '#44403c';
- ctx.fillRect(2, -2.5, 15, 5);
- ctx.fillStyle = '#292524';
- ctx.beginPath();
- ctx.arc(0, 0, 7, 0, Math.PI * 2);
- ctx.fill();
- ctx.restore();
-
- // Kouř stoupá a mizí, s ohněm postupně řídne
- for (let i = 0; i < 6; i++) {
- const phase = (now / 1400 + i / 6) % 1;
- ctx.fillStyle = `rgba(68,64,60,${(1 - phase) * (0.15 + 0.35 * fire)})`;
- ctx.beginPath();
- ctx.arc(w.x + Math.sin(phase * 5 + i) * 7 + phase * 6, w.y - phase * 34, 4 + phase * 9, 0, Math.PI * 2);
- ctx.fill();
- }
- if (fire <= 0) return;
- for (let i = 0; i < 5; i++) {
- const fx = w.x + Math.sin(i * 2.1) * 8 + Math.sin(now / 70 + i) * 2;
- const fy = w.y + Math.cos(i * 1.7) * 6 - Math.abs(Math.sin(now / 90 + i * 1.3)) * 4;
- const r = (4 + Math.abs(Math.sin(now / 110 + i)) * 4) * (0.4 + 0.6 * fire);
- ctx.fillStyle = 'rgba(249,115,22,0.85)';
- ctx.beginPath();
- ctx.arc(fx, fy, r, 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = 'rgba(253,224,71,0.9)';
- ctx.beginPath();
- ctx.arc(fx, fy + 1, r * 0.5, 0, Math.PI * 2);
- ctx.fill();
- }
-}
-
-function drawExplosion(ctx: CanvasRenderingContext2D, e: TankState['explosions'][number]) {
- const k = e.age / EXPLOSION_MS;
- if (k < 0 || k > 1) return;
- ctx.globalAlpha = 1 - k;
- ctx.fillStyle = '#f97316';
- ctx.beginPath();
- ctx.arc(e.x, e.y, e.r * (0.3 + 0.7 * k), 0, Math.PI * 2);
- ctx.fill();
- ctx.fillStyle = '#fde047';
- ctx.beginPath();
- ctx.arc(e.x, e.y, e.r * (0.15 + 0.4 * k), 0, Math.PI * 2);
- ctx.fill();
- ctx.strokeStyle = '#fff7ed';
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.arc(e.x, e.y, e.r * (0.4 + 0.8 * k), 0, Math.PI * 2);
- ctx.stroke();
- ctx.globalAlpha = 1;
-}
-
-/** Záblesk u ústí hlavně: hvězdice, která rychle zhasne. */
-function drawFlash(ctx: CanvasRenderingContext2D, p: TankPlayer, k: number) {
- if (k < 0 || k > 1) return;
- const tip = 24;
- const size = 10 * (1 - k * 0.5);
- ctx.save();
- ctx.translate(p.x + Math.cos(p.turret) * tip, p.y + Math.sin(p.turret) * tip);
- ctx.rotate(p.turret);
- ctx.globalAlpha = 1 - k;
- ctx.fillStyle = '#fde047';
- ctx.beginPath();
- for (let i = 0; i < 10; i++) {
- const a = i / 10 * Math.PI * 2;
- const r = i % 2 ? size * 0.4 : size * (i === 0 ? 1.6 : 1);
- ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r);
- }
- ctx.closePath();
- ctx.fill();
- ctx.fillStyle = '#fff';
- ctx.beginPath();
- ctx.arc(0, 0, size * 0.35, 0, Math.PI * 2);
- ctx.fill();
- ctx.restore();
- ctx.globalAlpha = 1;
-}
diff --git a/client/src/games/tetrisEngine.ts b/client/src/games/tetrisEngine.ts
deleted file mode 100644
index 02085e3..0000000
--- a/client/src/games/tetrisEngine.ts
+++ /dev/null
@@ -1,270 +0,0 @@
-/**
- * Herní logika Tetrisu bez Reactu — deska, kostky, rotace s odrazem od stěn,
- * mazání řádků a odpadní řádky od soupeřů.
- */
-
-export const WIDTH = 10;
-export const HEIGHT = 20;
-/** Hodnota buňky odpadního řádku (1–7 jsou kostky). */
-export const GARBAGE = 8;
-
-type Point = [number, number];
-
-/**
- * Kostky I, O, T, S, Z, J, L ve výchozí rotaci, velikost jejich čtverce pro rotaci
- * a počet různých poloh (O jedna; I, S, Z jen dvě, aby se při otáčení neposouvaly).
- */
-const SHAPES: { cells: Point[], box: number, states: 1 | 2 | 4 }[] = [
- { cells: [[0, 1], [1, 1], [2, 1], [3, 1]], box: 4, states: 2 },
- { cells: [[0, 0], [1, 0], [0, 1], [1, 1]], box: 2, states: 1 },
- { cells: [[1, 0], [0, 1], [1, 1], [2, 1]], box: 3, states: 4 },
- { cells: [[1, 0], [2, 0], [0, 1], [1, 1]], box: 3, states: 2 },
- { cells: [[0, 0], [1, 0], [1, 1], [2, 1]], box: 3, states: 2 },
- { cells: [[0, 0], [0, 1], [1, 1], [2, 1]], box: 3, states: 4 },
- { cells: [[2, 0], [0, 1], [1, 1], [2, 1]], box: 3, states: 4 },
-];
-
-/**
- * Předpočítané rotace pro každou kostku; rot + 1 = otočení proti směru hodinových ručiček.
- * T, J, L se otáčí kolem prostředního políčka. I, S, Z jen přepínají mezi vodorovnou
- * a svislou polohou (svislá = vodorovná otočená po směru, stejná osa jako v klasickém Tetrisu).
- */
-const ROTATIONS: Point[][][] = SHAPES.map(({ cells, box, states }) => {
- const ccw = (pts: Point[]) => pts.map(([x, y]) => [y, box - 1 - x] as Point);
- const cw = (pts: Point[]) => pts.map(([x, y]) => [box - 1 - y, x] as Point);
- if (states === 1) return [cells, cells, cells, cells];
- if (states === 2) return [cells, cw(cells), cells, cw(cells)];
- const result = [cells];
- for (let r = 1; r < 4; r++) result.push(ccw(result[r - 1]));
- return result;
-});
-
-/** Posuny zkoušené při rotaci, když kostka narazí (zjednodušený wall kick). */
-const KICKS: Point[] = [[0, 0], [-1, 0], [1, 0], [0, -1], [-2, 0], [2, 0]];
-
-/** Body za smazání 0–4 řádků naráz (násobí se úrovní). */
-const LINE_SCORES = [0, 100, 300, 500, 800];
-
-/** Odpadní řádky za smazání 0–4 řádků naráz (musí odpovídat GARBAGE_FOR_LINES na serveru). */
-const ATTACK_FOR_LINES = [0, 0, 1, 2, 4];
-
-export type Piece = { type: number, rot: number, x: number, y: number };
-
-/** Deterministický generátor (mulberry32) — stejný seed = stejné pořadí kostek u všech. */
-function mulberry32(seed: number) {
- let a = seed >>> 0;
- return () => {
- a = (a + 0x6D2B79F5) >>> 0;
- let t = a;
- t = Math.imul(t ^ (t >>> 15), t | 1);
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
- };
-}
-
-export const pieceCells = (p: Piece): Point[] => ROTATIONS[p.type][p.rot].map(([x, y]) => [p.x + x, p.y + y]);
-
-export type SessionCallbacks = {
- /**
- * Kostka dopadla; `cleared` = počet smazaných řádků,
- * `attack` = odpadní řádky pro soupeře po odražení vlastního čekajícího odpadu.
- */
- onLock: (cleared: number, attack: number) => void,
- onDeath: () => void,
-};
-
-/** Jedna rozehraná hra jednoho hráče. */
-export class TetrisSession {
- board: number[] = new Array(WIDTH * HEIGHT).fill(0);
- active: Piece | null = null;
- nextType = 0;
- score = 0;
- lines = 0;
- alive = true;
- /** Pořadové číslo padající kostky — dotykové gesto podle něj pozná, že jeho kostka mezitím dopadla. */
- pieceNo = 0;
- private random: () => number;
- private bag: number[] = [];
- private pendingGarbage = 0;
-
- constructor(seed: number, private readonly callbacks: SessionCallbacks) {
- this.random = mulberry32(seed);
- this.nextType = this.draw();
- this.spawn();
- }
-
- get level() {
- return Math.floor(this.lines / 10);
- }
-
- /** Interval gravitace v ms — s úrovní zrychluje. */
- get gravityMs() {
- return Math.max(80, 800 - this.level * 60);
- }
-
- /** Pozice "stínu" — kam by kostka dopadla. */
- get ghost(): Piece | null {
- if (!this.active) return null;
- let p = this.active;
- while (this.fits({ ...p, y: p.y + 1 })) p = { ...p, y: p.y + 1 };
- return p;
- }
-
- tick() {
- if (!this.alive || !this.active) return;
- if (!this.tryMove(0, 1)) this.lock();
- }
-
- move(dx: number) {
- if (this.alive) this.tryMove(dx, 0);
- }
-
- /** Zda kostka leží na dně či na jiných kostkách (další krok dolů by ji zamkl). */
- get grounded() {
- return !!this.active && !this.fits({ ...this.active, y: this.active.y + 1 });
- }
-
- softDrop() {
- if (!this.alive) return;
- if (this.tryMove(0, 1)) this.score += 1;
- else this.lock();
- }
-
- hardDrop() {
- if (!this.alive || !this.active) return;
- let dropped = 0;
- while (this.tryMove(0, 1)) dropped++;
- this.score += dropped * 2;
- this.lock();
- }
-
- rotate(dir: 1 | -1) {
- if (!this.alive || !this.active) return;
- const rot = (this.active.rot + dir + 4) % 4;
- for (const [kx, ky] of KICKS) {
- const candidate = { ...this.active, rot, x: this.active.x + kx, y: this.active.y + ky };
- if (this.fits(candidate)) {
- this.active = candidate;
- return;
- }
- }
- }
-
- /** Odpadní řádky čekají a přidají se po dopadu kostky, která nic nesmaže (jako v klasickém battle módu). */
- receiveGarbage(lines: number) {
- this.pendingGarbage += lines;
- }
-
- /** Deska včetně padající kostky (pro soupeře), znaky 0–8. */
- snapshot(): string {
- const cells = [...this.board];
- if (this.active) {
- for (const [x, y] of pieceCells(this.active)) {
- if (y >= 0) cells[y * WIDTH + x] = this.active.type + 1;
- }
- }
- return cells.join('');
- }
-
- private draw(): number {
- if (this.bag.length === 0) {
- this.bag = [0, 1, 2, 3, 4, 5, 6];
- for (let i = this.bag.length - 1; i > 0; i--) {
- const j = Math.floor(this.random() * (i + 1));
- [this.bag[i], this.bag[j]] = [this.bag[j], this.bag[i]];
- }
- }
- return this.bag.pop()!;
- }
-
- private spawn() {
- const type = this.nextType;
- this.nextType = this.draw();
- // Kostka se objeví celá nad deskou, aby ji šlo otočit dřív, než vjede do hrací plochy
- const bottom = Math.max(...ROTATIONS[type][0].map(([, y]) => y));
- const piece: Piece = { type, rot: 0, x: type === 1 ? 4 : 3, y: -bottom - 1 };
- if (!this.fits(piece)) {
- this.active = null;
- this.die();
- return;
- }
- this.active = piece;
- this.pieceNo++;
- }
-
- private fits(p: Piece) {
- return pieceCells(p).every(([x, y]) =>
- x >= 0 && x < WIDTH && y < HEIGHT && (y < 0 || this.board[y * WIDTH + x] === 0));
- }
-
- private tryMove(dx: number, dy: number) {
- if (!this.active) return false;
- const candidate = { ...this.active, x: this.active.x + dx, y: this.active.y + dy };
- if (!this.fits(candidate)) return false;
- this.active = candidate;
- return true;
- }
-
- private lock() {
- if (!this.active) return;
- const cells = pieceCells(this.active);
- // Kostka zamčená nad horním okrajem = konec hry
- if (cells.some(([, y]) => y < 0)) {
- this.die();
- return;
- }
- for (const [x, y] of cells) this.board[y * WIDTH + x] = this.active.type + 1;
- this.active = null;
-
- const cleared = this.clearLines();
- let attack = 0;
- if (cleared > 0) {
- this.score += LINE_SCORES[cleared] * (this.level + 1);
- this.lines += cleared;
- // Útok nejdřív odráží čekající odpad, soupeři jde jen zbytek
- attack = ATTACK_FOR_LINES[cleared] ?? 0;
- const cancelled = Math.min(attack, this.pendingGarbage);
- this.pendingGarbage -= cancelled;
- attack -= cancelled;
- }
- this.callbacks.onLock(cleared, attack);
- // Dokud hráč maže řádky, zbylý odpad ještě čeká
- if (cleared === 0 && this.pendingGarbage > 0) {
- const overflow = this.addGarbage(this.pendingGarbage);
- this.pendingGarbage = 0;
- if (overflow) {
- this.die();
- return;
- }
- }
- this.spawn();
- }
-
- private clearLines(): number {
- const rows: number[][] = [];
- for (let y = 0; y < HEIGHT; y++) rows.push(this.board.slice(y * WIDTH, (y + 1) * WIDTH));
- const kept = rows.filter(row => row.some(c => c === 0));
- const cleared = HEIGHT - kept.length;
- if (cleared > 0) {
- const empty = Array.from({ length: cleared }, () => new Array(WIDTH).fill(0));
- this.board = [...empty, ...kept].flat();
- }
- return cleared;
- }
-
- /** Přidá odpadní řádky zespodu s jednou dírou; vrátí true, pokud deska přetekla. */
- private addGarbage(lines: number): boolean {
- const overflow = this.board.slice(0, lines * WIDTH).some(c => c !== 0);
- const hole = Math.floor(Math.random() * WIDTH);
- const garbageRows = Array.from({ length: lines }, () =>
- Array.from({ length: WIDTH }, (_, x) => x === hole ? 0 : GARBAGE));
- this.board = [...this.board.slice(lines * WIDTH), ...garbageRows.flat()].slice(-WIDTH * HEIGHT);
- return overflow;
- }
-
- private die() {
- if (!this.alive) return;
- this.alive = false;
- this.callbacks.onDeath();
- }
-}
diff --git a/client/src/games/themeIcons.ts b/client/src/games/themeIcons.ts
index 1ac26f7..020f76d 100644
--- a/client/src/games/themeIcons.ts
+++ b/client/src/games/themeIcons.ts
@@ -1,20 +1,20 @@
import { StyleTheme, useSettings } from "../context/settings";
-export type GameId = 'reaction' | 'snake' | 'tetris' | 'pong' | 'drawing' | 'tanks';
+export type GameId = 'drawing';
-/** Ikony miniher podle vizuálního stylu aplikace. */
+/** Ikony miniher podle vizuálního stylu aplikace (ostatní minihry jsou v samostatné aplikaci Lobby). */
const ICONS: Record> = {
- simple: { reaction: '⚡', snake: '🐍', tetris: '🧱', pong: '🏓', drawing: '🎨', tanks: '💥' },
- // Dostihy: startovní vlajka, kůň místo hada, mrkve jako kostky, žokej, trofej, terč
- umamusume: { reaction: '🏁', snake: '🐎', tetris: '🥕', pong: '🏇', drawing: '🏆', tanks: '🎯' },
- // Duha, jednorožec, drahokamy, balónky, paleta a blesk Rainbow Dash
- mlp: { reaction: '🌈', snake: '🦄', tetris: '💎', pong: '🎈', drawing: '🖌️', tanks: '⚡' },
- // Pekelná korespondence: plamen, rudá nit, lampion, slaměná panenka, svitek, svíčka
- hellgirl: { reaction: '🔥', snake: '🪢', tetris: '🏮', pong: '🪆', drawing: '📜', tanks: '🕯️' },
- // Klub lehké hudby: bicí Ritsu, želva Ton-chan, dortíky k čaji, kytara Gitah, notový zápis a klávesy Mugi
- kon: { reaction: '🥁', snake: '🐢', tetris: '🍰', pong: '🎸', drawing: '🎼', tanks: '🎹' },
- // Kessoku Band: mikrofon Kity, kabel od kytary, krabice od manga, bicí Nijiky, sešit s texty Bocchi a její kytara
- bocchi: { reaction: '🎤', snake: '🔌', tetris: '📦', pong: '🥁', drawing: '📝', tanks: '🎸' },
+ simple: { drawing: '🎨' },
+ // Dostihy: trofej
+ umamusume: { drawing: '🏆' },
+ // Paleta Rarity
+ mlp: { drawing: '🖌️' },
+ // Pekelná korespondence: svitek
+ hellgirl: { drawing: '📜' },
+ // Klub lehké hudby: notový zápis
+ kon: { drawing: '🎼' },
+ // Kessoku Band: sešit s texty Bocchi
+ bocchi: { drawing: '📝' },
};
/** Styl z — pro místa mimo React kontext nastavení. */
diff --git a/client/src/hooks/useMinigameSecret.ts b/client/src/hooks/useMinigameSecret.ts
index 678d6d5..30fa411 100644
--- a/client/src/hooks/useMinigameSecret.ts
+++ b/client/src/hooks/useMinigameSecret.ts
@@ -1,11 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { openReactionDuel } from "../components/ReactionDuel";
-import { openSnakeGame } from "../components/SnakeGame";
-import { openTetrisGame } from "../components/TetrisGame";
-import { openPongGame } from "../components/PongGame";
import { openDrawingGame } from "../components/DrawingGame";
-import { openTankGame } from "../components/TankGame";
import { GameId, gameIcon } from "../games/themeIcons";
+import { useAppConfig } from "./useGroupLimits";
type Minigame = {
id: GameId,
@@ -16,36 +12,11 @@ type Minigame = {
open: () => void,
};
-/** Skryté minihry dostupné přes logo. Novou hru stačí přidat sem. */
+/**
+ * Skryté minihry, které zůstaly v Luncheru (ostatní jsou v samostatné aplikaci Lobby).
+ * Novou hru stačí přidat sem.
+ */
const MINIGAMES: Minigame[] = [
- {
- id: 'reaction',
- code: 'reakce',
- name: 'Reakční hra',
- description: 'Kdo klikne nejrychleji? Hraje se s kolegy online, 3 kola.',
- open: openReactionDuel,
- },
- {
- id: 'snake',
- code: 'snake',
- name: 'Had',
- description: 'Had pro více hráčů, ovládání šipkami nebo WSAD. Vyhrává poslední přeživší.',
- open: openSnakeGame,
- },
- {
- id: 'tetris',
- code: 'tetris',
- name: 'Tetris',
- description: 'Tetris pro více hráčů — smazané řádky posílají soupeřům odpad.',
- open: openTetrisGame,
- },
- {
- id: 'pong',
- code: 'pong',
- name: 'Pong',
- description: 'Pong pro dva, ovládání šipkami nebo W/S. Hraje se do 5 bodů.',
- open: openPongGame,
- },
{
id: 'drawing',
code: 'kresleni',
@@ -53,25 +24,23 @@ const MINIGAMES: Minigame[] = [
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
open: openDrawingGame,
},
- {
- id: 'tanks',
- code: 'tanky',
- name: 'Tanky',
- description: 'Tanková bitva pro více hráčů (i s boty), střely se odráží od zdí. Na čas nebo na poslední přeživší.',
- open: openTankGame,
- },
];
const HELP_CODE = 'help';
const SECRET_CLICKS = 3;
/** Max. pauza mezi kliknutími na logo, aby se počítala do série (ms). */
const SECRET_CLICK_GAP_MS = 400;
-/** Jak dlouho po sérii kliknutí lze napsat kód (ms). */
+/** Jak dlouho po sérii kliknutí (případně po návratu z Lobby do této záložky) lze napsat kód (ms). */
const SECRET_ARM_MS = 8000;
-function printHelp() {
+function printHelp(lobbyUrl?: string) {
console.log('%c🎮 Luncher minihry', 'font-size: 16px; font-weight: bold');
- console.log('Klikněte 3× na logo a napište kód hry:');
+ if (lobbyUrl) {
+ console.log(`Klikněte 3× na logo — otevře se Lobby s dalšími minihrami (${lobbyUrl}).`);
+ console.log('Po návratu do Luncheru lze napsat kód hry:');
+ } else {
+ console.log('Klikněte 3× na logo a napište kód hry:');
+ }
console.table(MINIGAMES.map(g => ({ kód: g.code, hra: `${gameIcon(g.id)} ${g.name}`, popis: g.description })));
}
@@ -79,12 +48,14 @@ function printHelp() {
const normalizeCode = (text: string) => text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim();
/**
- * Skrytý vstup do miniher: 3× rychle kliknout na logo a pak napsat kód hry,
- * případně "help" pro výpis her do konzole prohlížeče.
+ * Skrytý vstup do miniher: 3× rychle kliknout na logo. Je-li nastavena adresa Lobby, otevře se v nové záložce.
+ * Poté lze (po návratu do Luncheru) napsat kód hry, která zůstala v Luncheru, případně "help" pro výpis do konzole prohlížeče.
* Jedno či dvě kliknutí se chovají jako běžný odkaz (jen s malým zpožděním).
- * Na dotykových zařízeních se kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici.
+ * Bez Lobby se na dotykových zařízeních kód píše do skrytého pole (`inputRef`), které otevře softwarovou klávesnici.
+ * Po otevření Lobby klávesnici po návratu do záložky otevřít nelze (chce gesto uživatele), na mobilu tak kód zadat nepůjde.
*/
export function useMinigameSecret(href: string) {
+ const { lobbyUrl } = useAppConfig();
const [armed, setArmed] = useState(false);
const clicks = useRef(0);
const timer = useRef(undefined);
@@ -98,11 +69,16 @@ export function useMinigameSecret(href: string) {
clicks.current++;
if (clicks.current >= SECRET_CLICKS) {
clicks.current = 0;
- // Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře
- const input = inputRef.current;
- if (input) {
- input.value = '';
- input.focus({ preventScroll: true });
+ if (lobbyUrl) {
+ // Otevření musí proběhnout přímo v obsluze kliknutí, jinak ho prohlížeč zablokuje jako vyskakovací okno
+ window.open(lobbyUrl, '_blank', 'noopener');
+ } else {
+ // Fokus musí proběhnout přímo v obsluze kliknutí, jinak iOS klávesnici neotevře
+ const input = inputRef.current;
+ if (input) {
+ input.value = '';
+ input.focus({ preventScroll: true });
+ }
}
setArmed(true);
return;
@@ -111,7 +87,7 @@ export function useMinigameSecret(href: string) {
clicks.current = 0;
window.location.assign(href);
}, SECRET_CLICK_GAP_MS);
- }, [href]);
+ }, [href, lobbyUrl]);
useEffect(() => {
if (!armed) return;
@@ -121,7 +97,7 @@ export function useMinigameSecret(href: string) {
const check = (text: string) => {
if (text.endsWith(HELP_CODE)) {
setArmed(false);
- printHelp();
+ printHelp(lobbyUrl);
return;
}
const game = MINIGAMES.find(g => text.endsWith(g.code));
@@ -138,19 +114,34 @@ export function useMinigameSecret(href: string) {
};
// Softwarová klávesnice (Android) posílá v keydown jen "Unidentified", proto se čte celý text pole
const onInput = () => check(normalizeCode(input?.value ?? ''));
- const disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
+ // Limit běží, jen když je záložka vidět — po odchodu do Lobby se zastaví a po návratu začne znovu
+ let disarm: number | undefined;
+ const startDisarm = () => {
+ window.clearTimeout(disarm);
+ disarm = window.setTimeout(() => setArmed(false), SECRET_ARM_MS);
+ };
+ const onVisibility = () => {
+ if (document.visibilityState === 'visible') {
+ startDisarm();
+ } else {
+ window.clearTimeout(disarm);
+ }
+ };
+ startDisarm();
window.addEventListener('keydown', onKey);
input?.addEventListener('input', onInput);
+ document.addEventListener('visibilitychange', onVisibility);
return () => {
window.clearTimeout(disarm);
window.removeEventListener('keydown', onKey);
input?.removeEventListener('input', onInput);
+ document.removeEventListener('visibilitychange', onVisibility);
if (input) {
input.value = '';
input.blur();
}
};
- }, [armed]);
+ }, [armed, lobbyUrl]);
useEffect(() => () => window.clearTimeout(timer.current), []);
diff --git a/client/src/hooks/useTouchDevice.ts b/client/src/hooks/useTouchDevice.ts
deleted file mode 100644
index 36771ce..0000000
--- a/client/src/hooks/useTouchDevice.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { useEffect, useState } from "react";
-
-/** Libovolné dotykové ovládání — iPad se hlásí jako Mac, takže se nedá spoléhat na user agent. */
-const QUERY = '(any-pointer: coarse)';
-
-/** Zda má zařízení dotykový displej (podle toho minihry ukazují dotyková tlačítka a nápovědu). */
-export function useTouchDevice() {
- const [touch, setTouch] = useState(() => window.matchMedia(QUERY).matches);
- useEffect(() => {
- const media = window.matchMedia(QUERY);
- const onChange = () => setTouch(media.matches);
- media.addEventListener('change', onChange);
- return () => media.removeEventListener('change', onChange);
- }, []);
- return touch;
-}
diff --git a/client/src/index.tsx b/client/src/index.tsx
index ef7908a..bc4495a 100644
--- a/client/src/index.tsx
+++ b/client/src/index.tsx
@@ -24,6 +24,7 @@ getConfig().then(({ data }) => {
loaded: true,
trustedHeaders: data?.auth?.trustedHeaders,
storeAdmin: data?.features?.storeAdmin,
+ lobbyUrl: data?.links?.lobbyUrl ?? undefined,
groupLimits: data?.limits?.group,
});
if (data?.sentry.dsn) {
diff --git a/client/src/utils/appConfig.ts b/client/src/utils/appConfig.ts
index 1ea6db0..52992cf 100644
--- a/client/src/utils/appConfig.ts
+++ b/client/src/utils/appConfig.ts
@@ -22,6 +22,8 @@ export type AppRuntimeConfig = {
trustedHeaders?: boolean,
/** Správa obchodů je zapnutá (server má admin heslo); undefined = nevíme */
storeAdmin?: boolean,
+ /** Adresa aplikace Lobby s minihrami; undefined = není nastavena nebo nevíme */
+ lobbyUrl?: string,
groupLimits: GroupLimits,
};
diff --git a/client/src/utils/arrowNavigation.ts b/client/src/utils/arrowNavigation.ts
index e74e66b..16ef879 100644
--- a/client/src/utils/arrowNavigation.ts
+++ b/client/src/utils/arrowNavigation.ts
@@ -1,7 +1,7 @@
/**
* Zda se má stisk klávesy ignorovat pro navigaci šipkami mezi dny/týdny na pozadí stránky.
* Navigace se nemá provádět, pokud uživatel píše do formulářového pole, nebo pokud je otevřený
- * modální dialog (typicky minihra jako Tetris či Had, která šipky používá k ovládání) –
+ * modální dialog (např. minihra, která klávesnici používá k ovládání) –
* jinak by se stránka na pozadí neustále přepínala a zbytečně přenačítala data ze serveru.
*/
export function isArrowNavigationBlocked(e: KeyboardEvent): boolean {
diff --git a/k8s/base/server-configmap.yaml b/k8s/base/server-configmap.yaml
index 3959a8d..d9d8528 100644
--- a/k8s/base/server-configmap.yaml
+++ b/k8s/base/server-configmap.yaml
@@ -10,3 +10,4 @@ data:
REDIS_PORT: "6379"
PORT: "3001"
HOST: "0.0.0.0"
+ LOBBY_URL: "http://lobby.localhost"
diff --git a/server/.env.template b/server/.env.template
index 9786e6a..0343f99 100644
--- a/server/.env.template
+++ b/server/.env.template
@@ -52,6 +52,10 @@
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
# REFRESH_BYPASS_PASSWORD=
+# Adresa aplikace Lobby s minihrami. Skrytý odkaz (3× kliknutí na logo) ji otevře v nové záložce.
+# Bez vyplnění 3× kliknutí jen čeká na kód hry, která zůstala v Luncheru (kreslení).
+# LOBBY_URL='https://lobby.example.com'
+
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
# ADMIN_PASSWORD=
diff --git a/server/changelogs/2026-10-21.json b/server/changelogs/2026-10-21.json
index f176d97..1df8de0 100644
--- a/server/changelogs/2026-10-21.json
+++ b/server/changelogs/2026-10-21.json
@@ -2,5 +2,6 @@
"U oběda v restauraci si můžete zadat cenu (ikonka cenovky u vaší volby) — promítne se do měsíčního přehledu stravování a předvyplní se i v „Zaplatit za všechny“",
"V lobby miniher (Pong, Reakční hra, Had, Tetris, Tanky) si hráči mohou psát v chatu, emotikony se vkládají jedním kliknutím 😉",
"Tanky mají nově výchozí směrové ovládání — šipka nebo WSAD určuje směr jízdy na mapě; původní tankové ovládání (vpřed/vzad a otáčení) lze zapnout volbou Ovládání",
- "Tanky: větší mapy s více překážkami, vlastní tank je výrazně označený (při startu a po návratu do hry na něj ukazuje šipka) a pod hrací plochou je legenda bonusů"
+ "Tanky: větší mapy s více překážkami, vlastní tank je výrazně označený (při startu a po návratu do hry na něj ukazuje šipka) a pod hrací plochou je legenda bonusů",
+ "Minihry se přestěhovaly do vlastní aplikace 😉"
]
diff --git a/server/src/index.ts b/server/src/index.ts
index eabfc4e..8b840bf 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -150,6 +150,9 @@ app.get("/api/config", (_req, res) => {
features: {
storeAdmin: !!process.env.ADMIN_PASSWORD,
},
+ links: {
+ lobbyUrl: process.env.LOBBY_URL || null,
+ },
});
});
diff --git a/server/src/minigames.ts b/server/src/minigames.ts
index a8581ad..8179754 100644
--- a/server/src/minigames.ts
+++ b/server/src/minigames.ts
@@ -1,7 +1,7 @@
-import { Server, Socket } from "socket.io";
+import { Socket } from "socket.io";
import { getLogin } from "./auth";
-/** Sdílené pomůcky pro websocketové minihry (reakční hra, had). */
+/** Pomůcky pro websocketové minihry (kreslení). Ostatní minihry jsou v samostatné aplikaci Lobby. */
/** Chyba určená hráči — posílá se zpět klientovi, nic se neloguje. */
export class GameError extends Error { }
@@ -60,19 +60,3 @@ export function gameHandler(socket: Socket, errorEvent: string, action: (login:
/** Minimální počet hráčů — lokálně lze hrát i sám. */
export const minPlayersForEnv = (prod: number) => process.env.NODE_ENV === 'development' ? 1 : prod;
-
-/**
- * Jednoduchý chat v lobby miniher — zprávy se nikde neukládají, server je jen
- * přepošle všem (klient si je filtruje podle hry).
- */
-export function registerLobbyChat(io: Server) {
- io.on('connection', socket => {
- socket.on('lobbychat:send', gameHandler(socket, 'lobbychat:error', (login, payload) => {
- const { game, text } = (payload ?? {}) as { game?: unknown, text?: unknown };
- if (typeof game !== 'string' || typeof text !== 'string') return;
- const trimmed = text.trim().slice(0, 200);
- if (!trimmed) return;
- io.emit('lobbychat:message', { game: game.slice(0, 20), login, text: trimmed });
- }));
- });
-}
diff --git a/server/src/pongGame.ts b/server/src/pongGame.ts
deleted file mode 100644
index 0c763e8..0000000
--- a/server/src/pongGame.ts
+++ /dev/null
@@ -1,429 +0,0 @@
-import { Server } from "socket.io";
-import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
-
-/**
- * Pong 1v1 přes websocket.
- *
- * Server drží fyziku (míček, pálky, skóre) a posílá snímky sledujícím ~30× za sekundu;
- * hráči posílají jen směr pálky (nahoru / dolů / stát). Kdo první dá {@link WIN_SCORE}
- * bodů, vyhrává. Ostatní se mohou dívat. Stav je v paměti, předpokládá jednu repliku.
- *
- * Pálku lze ovládat klávesami (směr) i myší (cílová výška — pálka k ní rychle dojede).
- * Zakladatel lobby může místo soupeře přidat bota s nastavitelnou obtížností.
- */
-
-export const FIELD_WIDTH = 800;
-export const FIELD_HEIGHT = 500;
-export const PADDLE_HEIGHT = 90;
-export const PADDLE_WIDTH = 12;
-/** Vzdálenost pálky od kraje hřiště. */
-export const PADDLE_MARGIN = 20;
-export const BALL_SIZE = 12;
-export const TICK_MS = 33;
-/** Rychlosti v jednotkách hřiště za sekundu. */
-export const PADDLE_SPEED = 420;
-/** Rychlost pálky při ovládání myší — rychlé, ale ne okamžité přeskočení. */
-export const MOUSE_PADDLE_SPEED = 1600;
-export const BALL_START_SPEED = 330;
-export const BALL_MAX_SPEED = 900;
-/** Každý odpal míček zrychlí o tento násobek. */
-export const BALL_SPEEDUP = 1.07;
-/** Nejostřejší úhel odrazu od pálky (rad). */
-const MAX_BOUNCE_ANGLE = Math.PI / 3.2;
-export const WIN_SCORE = 5;
-export const COUNTDOWN_SECONDS = 3;
-/** Pauza po gólu, než se míček znovu rozehraje (ms). */
-export const SERVE_PAUSE_MS = 1000;
-export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
-export const WATCH_ROOM = 'pong-watchers';
-
-export type PongPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
-export type BotLevel = 'easy' | 'medium' | 'hard' | 'impossible';
-
-type BotProfile = {
- label: string,
- /** Rychlost pálky bota. */
- speed: number,
- /** Jak často bot přehodnocuje, kam jet (ms) — simuluje reakční dobu. */
- reactMs: number,
- /** Max. odchylka míření (± jednotek hřiště). */
- error: number,
- /** Počítá, kam míček dopadne (včetně odrazů), nebo jen sleduje jeho výšku. */
- predict: boolean,
-};
-
-export const BOT_PROFILES: Record = {
- easy: { label: 'Lehký', speed: 250, reactMs: 320, error: 70, predict: false },
- medium: { label: 'Střední', speed: 340, reactMs: 200, error: 40, predict: true },
- hard: { label: 'Těžký', speed: 450, reactMs: 110, error: 20, predict: true },
- impossible: { label: 'Nemožný', speed: 900, reactMs: TICK_MS, error: 0, predict: true },
-};
-
-/** Jméno bota — s emoji, aby nekolidovalo s přihlašovacími jmény. */
-export const botLogin = (level: BotLevel) => `🤖 Bot (${BOT_PROFILES[level].label})`;
-export type PaddleInput = 'up' | 'down' | 'stop';
-
-export type PongPlayer = {
- login: string,
- /** 0 = levá pálka, 1 = pravá. */
- side: 0 | 1,
- /** Svislá pozice středu pálky. */
- y: number,
- score: number,
- /** Obtížnost, pokud jde o bota. */
- bot?: BotLevel,
-};
-
-export type PongState = {
- phase: PongPhase,
- host?: string,
- players: PongPlayer[],
- ball: { x: number, y: number },
- countdown?: number,
- winner?: string,
- width: number,
- height: number,
-};
-
-type InternalPlayer = PongPlayer & {
- input: PaddleInput,
- /** Cílová výška při ovládání myší (přebíjí klávesy, dokud se nezmáčkne šipka). */
- target?: number,
- /** Kam bot míří a kdy se znovu rozhodne. */
- botAim?: number,
- botThinkAt?: number,
-};
-
-const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
-
-export class PongGame {
- private phase: PongPhase = 'idle';
- private host?: string;
- private players: InternalPlayer[] = [];
- private ball = { x: FIELD_WIDTH / 2, y: FIELD_HEIGHT / 2, vx: 0, vy: 0 };
- private countdown = 0;
- private winner?: string;
- /** Míček čeká na rozehrání po gólu. */
- private serveAt = 0;
- private lastTick = 0;
- private timer: unknown;
-
- constructor(
- private readonly broadcastAll: (state: PongState) => void,
- private readonly broadcastWatchers: (state: PongState) => void,
- private readonly clock: Clock = defaultClock,
- ) { }
-
- getState(): PongState {
- return {
- phase: this.phase,
- host: this.host,
- players: this.players.map(p => ({ login: p.login, side: p.side, y: Math.round(p.y), score: p.score, bot: p.bot })),
- ball: { x: Math.round(this.ball.x), y: Math.round(this.ball.y) },
- countdown: this.phase === 'countdown' ? this.countdown : undefined,
- winner: this.winner,
- width: FIELD_WIDTH,
- height: FIELD_HEIGHT,
- };
- }
-
- join(login: string) {
- if (this.phase === 'idle' || this.phase === 'finished') {
- this.reset();
- this.phase = 'lobby';
- this.host = login;
- this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
- } else if (this.phase !== 'lobby') {
- throw new GameError('Hra už běží, počkejte na další.');
- }
- if (this.players.some(p => p.login === login)) return;
- if (this.players.length >= 2) {
- throw new GameError('Pong je jen pro dva hráče.');
- }
- this.players.push({ login, side: this.players.length as 0 | 1, y: FIELD_HEIGHT / 2, score: 0, input: 'stop' });
- this.emitAll();
- }
-
- /** Zakladatel přidá do lobby bota místo druhého hráče. */
- addBot(login: string, level: unknown) {
- if (this.phase !== 'lobby') {
- throw new GameError('Není otevřené žádné lobby.');
- }
- if (login !== this.host) {
- throw new GameError('Bota může přidat jen ten, kdo lobby založil.');
- }
- if (typeof level !== 'string' || !(level in BOT_PROFILES)) {
- throw new GameError('Neznámá obtížnost bota.');
- }
- if (this.players.length >= 2) {
- throw new GameError('Pong je jen pro dva hráče.');
- }
- const bot = level as BotLevel;
- this.players.push({ login: botLogin(bot), side: 1, y: FIELD_HEIGHT / 2, score: 0, input: 'stop', bot });
- this.emitAll();
- }
-
- /** Zakladatel odebere bota z lobby. */
- removeBot(login: string) {
- if (this.phase !== 'lobby' || login !== this.host) return;
- this.players = this.players.filter(p => !p.bot);
- this.emitAll();
- }
-
- leave(login: string) {
- if (this.phase !== 'lobby' || !this.players.some(p => p.login === login)) return;
- if (login === this.host) {
- this.cancel();
- return;
- }
- this.players = this.players.filter(p => p.login !== login);
- this.emitAll();
- }
-
- start(login: string) {
- if (this.phase !== 'lobby') {
- throw new GameError('Není otevřené žádné lobby.');
- }
- if (login !== this.host) {
- throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
- }
- if (this.players.length < 2) {
- throw new GameError('Na Pong jsou potřeba dva hráči.');
- }
- this.players.forEach(p => {
- p.y = FIELD_HEIGHT / 2;
- p.score = 0;
- p.input = 'stop';
- p.target = undefined;
- p.botAim = undefined;
- p.botThinkAt = undefined;
- });
- this.phase = 'countdown';
- this.countdown = COUNTDOWN_SECONDS;
- this.emitAll();
- this.schedule(() => this.countdownStep(), 1000);
- }
-
- input(login: string, input: unknown) {
- const p = this.players.find(x => x.login === login);
- if (!p || p.bot || (input !== 'up' && input !== 'down' && input !== 'stop')) return;
- p.input = input;
- // Šipka přebíjí myš; puštění klávesy myš neruší
- if (input !== 'stop') p.target = undefined;
- }
-
- /** Ovládání myší: pálka jede rychle k zadané výšce. */
- aim(login: string, y: unknown) {
- const p = this.players.find(x => x.login === login);
- if (!p || p.bot || typeof y !== 'number' || !Number.isFinite(y)) return;
- p.target = clamp(y, 0, FIELD_HEIGHT);
- p.input = 'stop';
- }
-
- private countdownStep() {
- this.countdown--;
- if (this.countdown > 0) {
- this.emitAll();
- this.schedule(() => this.countdownStep(), 1000);
- return;
- }
- this.phase = 'playing';
- this.serve(Math.round(this.clock.random()) as 0 | 1);
- this.lastTick = this.clock.now();
- this.emitAll();
- this.schedule(() => this.step(), TICK_MS);
- }
-
- /** Míček do středu, po pauze letí k dané straně pod náhodným úhlem. */
- private serve(towards: 0 | 1) {
- const angle = (this.clock.random() - 0.5) * (Math.PI / 3);
- const dir = towards === 0 ? -1 : 1;
- this.ball = {
- x: FIELD_WIDTH / 2,
- y: FIELD_HEIGHT / 2,
- vx: dir * BALL_START_SPEED * Math.cos(angle),
- vy: BALL_START_SPEED * Math.sin(angle),
- };
- this.serveAt = this.clock.now() + SERVE_PAUSE_MS;
- }
-
- private step() {
- const now = this.clock.now();
- // Skutečně uplynulý čas, ať zpožděný tick nezpomalí hru (max. 100 ms proti skokům)
- const dt = Math.min(0.1, (now - this.lastTick) / 1000);
- this.lastTick = now;
-
- for (const p of this.players) {
- if (p.bot) {
- this.moveBot(p, now, dt);
- } else if (p.target !== undefined) {
- p.y = this.approach(p.y, p.target, MOUSE_PADDLE_SPEED * dt);
- } else {
- const dir = p.input === 'up' ? -1 : p.input === 'down' ? 1 : 0;
- p.y = clamp(p.y + dir * PADDLE_SPEED * dt, PADDLE_HEIGHT / 2, FIELD_HEIGHT - PADDLE_HEIGHT / 2);
- }
- }
-
- if (now >= this.serveAt) {
- this.moveBall(dt);
- }
- if (this.phase !== 'playing') return;
- this.emitWatchers();
- this.schedule(() => this.step(), TICK_MS);
- }
-
- /** Posune pálku k cíli nejvýš o `maxStep`, v mezích hřiště. */
- private approach(y: number, target: number, maxStep: number) {
- const goal = clamp(target, PADDLE_HEIGHT / 2, FIELD_HEIGHT - PADDLE_HEIGHT / 2);
- return Math.abs(goal - y) <= maxStep ? goal : y + Math.sign(goal - y) * maxStep;
- }
-
- /** Bot se v intervalu reakční doby rozhodne, kam jet, a jede tam svou rychlostí. */
- private moveBot(p: InternalPlayer, now: number, dt: number) {
- const profile = BOT_PROFILES[p.bot!];
- if (p.botThinkAt === undefined || now >= p.botThinkAt) {
- p.botThinkAt = now + profile.reactMs;
- const b = this.ball;
- const incoming = p.side === 0 ? b.vx < 0 : b.vx > 0;
- let aim: number;
- if (!incoming || now < this.serveAt) {
- // Míček letí pryč — vrátit se doprostřed
- aim = FIELD_HEIGHT / 2;
- } else if (profile.predict) {
- aim = this.predictY(p.side);
- } else {
- aim = b.y;
- }
- p.botAim = aim + (this.clock.random() * 2 - 1) * profile.error;
- }
- p.y = this.approach(p.y, p.botAim ?? FIELD_HEIGHT / 2, profile.speed * dt);
- }
-
- /** Výška, ve které míček dorazí k pálce dané strany (s odrazy od horní a dolní stěny). */
- private predictY(side: 0 | 1): number {
- const b = this.ball;
- const r = BALL_SIZE / 2;
- const faceX = side === 0 ? PADDLE_MARGIN + PADDLE_WIDTH + r : FIELD_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH - r;
- const t = (faceX - b.x) / b.vx;
- if (!Number.isFinite(t) || t < 0) return b.y;
- // Odrazy "rozbalíme": pohyb v pásmu [r, H - r] je periodický s periodou 2 × výška pásma
- const span = FIELD_HEIGHT - 2 * r;
- const period = 2 * span;
- let y = ((b.y + b.vy * t - r) % period + period) % period;
- if (y > span) y = period - y;
- return y + r;
- }
-
- private moveBall(dt: number) {
- const b = this.ball;
- b.x += b.vx * dt;
- b.y += b.vy * dt;
- const r = BALL_SIZE / 2;
-
- if (b.y - r < 0) { b.y = r; b.vy = Math.abs(b.vy); }
- if (b.y + r > FIELD_HEIGHT) { b.y = FIELD_HEIGHT - r; b.vy = -Math.abs(b.vy); }
-
- for (const p of this.players) {
- const faceX = p.side === 0 ? PADDLE_MARGIN + PADDLE_WIDTH : FIELD_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH;
- const movingTowards = p.side === 0 ? b.vx < 0 : b.vx > 0;
- const reached = p.side === 0 ? b.x - r <= faceX : b.x + r >= faceX;
- const behind = p.side === 0 ? b.x + r < PADDLE_MARGIN : b.x - r > FIELD_WIDTH - PADDLE_MARGIN;
- if (!movingTowards || !reached || behind) continue;
- const offset = (b.y - p.y) / (PADDLE_HEIGHT / 2 + r);
- if (Math.abs(offset) > 1) continue;
- // Úhel odrazu podle místa zásahu na pálce, rychlost postupně roste
- const speed = Math.min(BALL_MAX_SPEED, Math.hypot(b.vx, b.vy) * BALL_SPEEDUP);
- const angle = offset * MAX_BOUNCE_ANGLE;
- b.vx = (p.side === 0 ? 1 : -1) * speed * Math.cos(angle);
- b.vy = speed * Math.sin(angle);
- b.x = p.side === 0 ? faceX + r : faceX - r;
- }
-
- if (b.x < 0) this.point(1);
- else if (b.x > FIELD_WIDTH) this.point(0);
- }
-
- private point(side: 0 | 1) {
- const scorer = this.players.find(p => p.side === side);
- if (!scorer) return;
- scorer.score++;
- if (scorer.score >= WIN_SCORE) {
- this.winner = scorer.login;
- this.phase = 'finished';
- this.clearTimer();
- this.emitAll();
- return;
- }
- // Podává se k tomu, kdo bod ztratil
- this.serve(side === 0 ? 1 : 0);
- this.emitAll();
- }
-
- private cancel() {
- this.reset();
- this.emitAll();
- }
-
- private reset() {
- this.clearTimer();
- this.phase = 'idle';
- this.host = undefined;
- this.players = [];
- this.winner = undefined;
- this.ball = { x: FIELD_WIDTH / 2, y: FIELD_HEIGHT / 2, vx: 0, vy: 0 };
- }
-
- private schedule(fn: () => void, ms: number) {
- this.clearTimer();
- this.timer = this.clock.setTimeout(() => {
- this.timer = undefined;
- fn();
- }, ms);
- }
-
- private clearTimer() {
- if (this.timer !== undefined) {
- this.clock.clearTimeout(this.timer);
- this.timer = undefined;
- }
- }
-
- private emitAll() {
- this.broadcastAll(this.getState());
- }
-
- private emitWatchers() {
- this.broadcastWatchers(this.getState());
- }
-
- /** Pro testy: nastaví míček na konkrétní pozici a rychlost. */
- setBallForTest(x: number, y: number, vx: number, vy: number) {
- this.ball = { x, y, vx, vy };
- this.serveAt = 0;
- }
-}
-
-/** Zaregistruje websocket handlery Pongu. */
-export function registerPongGame(io: Server) {
- const game = new PongGame(
- state => io.emit('pong:state', state),
- state => io.to(WATCH_ROOM).emit('pong:state', state),
- );
-
- io.on('connection', socket => {
- const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'pong:error', action);
- socket.on('pong:sync', () => socket.emit('pong:state', game.getState()));
- socket.on('pong:watch', () => {
- socket.join(WATCH_ROOM);
- socket.emit('pong:state', game.getState());
- });
- socket.on('pong:unwatch', () => socket.leave(WATCH_ROOM));
- socket.on('pong:join', handle(login => game.join(login)));
- socket.on('pong:leave', handle(login => game.leave(login)));
- socket.on('pong:start', handle(login => game.start(login)));
- socket.on('pong:input', handle((login, payload) => game.input(login, (payload as { input?: unknown })?.input)));
- socket.on('pong:aim', handle((login, payload) => game.aim(login, (payload as { y?: unknown })?.y)));
- socket.on('pong:addBot', handle((login, payload) => game.addBot(login, (payload as { level?: unknown })?.level)));
- socket.on('pong:removeBot', handle(login => game.removeBot(login)));
- });
-}
diff --git a/server/src/reactionDuel.ts b/server/src/reactionDuel.ts
deleted file mode 100644
index ed5670b..0000000
--- a/server/src/reactionDuel.ts
+++ /dev/null
@@ -1,250 +0,0 @@
-import { Server } from "socket.io";
-import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } from "./minigames";
-
-/**
- * Reakční hra — krátká multiplayerová hra přes websocket.
- *
- * Hráči se přidají do lobby, pak proběhnou {@link ROUNDS} kola: server po náhodné
- * prodlevě vyhlásí "TEĎ!" a měří čas od vyhlášení do přijetí kliknutí. Kdo klikne
- * před vyhlášením (předčasný start) nebo neklikne vůbec, dostane trestný čas.
- * Pořadí určuje součet časů — nejnižší vyhrává.
- *
- * Veškeré časování drží server, klient jen posílá kliknutí. Stav je v paměti procesu,
- * takže hra předpokládá jednu repliku serveru (broadcast přes Redis adapter funguje,
- * ale akce z jiné repliky by stav neviděly).
- */
-
-export const ROUNDS = 3;
-/** Trestný čas za předčasný start nebo nekliknutí (ms). */
-export const PENALTY_MS = 2000;
-/** Jak dlouho po vyhlášení se čeká na kliknutí (ms). */
-export const CLICK_WINDOW_MS = 2000;
-export const MIN_DELAY_MS = 2000;
-export const MAX_DELAY_MS = 6000;
-/** Pauza mezi koly pro zobrazení výsledku kola (ms). */
-export const ROUND_PAUSE_MS = 2500;
-/** Neodstartované lobby se po této době zruší (ms). */
-export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
-export const MAX_PLAYERS = 12;
-
-export type DuelPhase = 'idle' | 'lobby' | 'waiting' | 'go' | 'roundResult' | 'finished';
-
-export type RoundResult = {
- /** Reakční čas v ms, nebo null při předčasném startu / nekliknutí. */
- ms: number | null,
- falseStart?: boolean,
-};
-
-export type DuelPlayer = {
- login: string,
- /** Výsledky jednotlivých kol. */
- rounds: RoundResult[],
- /** Součet časů včetně trestů. */
- total: number,
-};
-
-export type DuelState = {
- phase: DuelPhase,
- host?: string,
- round: number,
- totalRounds: number,
- players: DuelPlayer[],
- /** Login posledního hráče (jen ve fázi finished). */
- loser?: string,
- /** Login nejrychlejšího hráče celé hry (jen ve fázi finished). */
- winner?: string,
- minPlayers: number,
-};
-
-export class ReactionDuel {
- private phase: DuelPhase = 'idle';
- private host?: string;
- private round = 0;
- private players = new Map();
- /** Výsledky aktuálního kola. */
- private current = new Map();
- private goAt = 0;
- private timer: unknown;
- private loser?: string;
- private winner?: string;
-
- constructor(
- private readonly broadcast: (state: DuelState) => void,
- private readonly minPlayers = 2,
- private readonly clock: Clock = defaultClock,
- ) { }
-
- getState(): DuelState {
- return {
- phase: this.phase,
- host: this.host,
- round: this.round,
- totalRounds: ROUNDS,
- players: [...this.players.values()].map(p => ({ ...p, rounds: [...p.rounds] })),
- loser: this.loser,
- winner: this.winner,
- minPlayers: this.minPlayers,
- };
- }
-
- /** Přidá hráče; pokud žádná hra neběží, založí nové lobby. */
- join(login: string) {
- if (this.phase === 'idle' || this.phase === 'finished') {
- this.reset();
- this.phase = 'lobby';
- this.host = login;
- this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
- } else if (this.phase !== 'lobby') {
- throw new GameError('Hra už běží, počkejte na další.');
- }
- if (!this.players.has(login)) {
- if (this.players.size >= MAX_PLAYERS) {
- throw new GameError('Lobby je plné.');
- }
- this.players.set(login, { login, rounds: [], total: 0 });
- }
- this.emit();
- }
-
- /** Odebere hráče z lobby; odchodem zakladatele se lobby ruší. */
- leave(login: string) {
- if (this.phase !== 'lobby' || !this.players.has(login)) {
- return;
- }
- this.players.delete(login);
- if (login === this.host || this.players.size === 0) {
- this.cancel();
- return;
- }
- this.emit();
- }
-
- /** Spustí hru, smí jen zakladatel lobby. */
- start(login: string) {
- if (this.phase !== 'lobby') {
- throw new GameError('Není otevřené žádné lobby.');
- }
- if (login !== this.host) {
- throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
- }
- if (this.players.size < this.minPlayers) {
- throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
- }
- this.nextRound();
- }
-
- /** Zpracuje kliknutí hráče. */
- click(login: string) {
- if (!this.players.has(login) || this.current.has(login)) {
- return;
- }
- if (this.phase === 'waiting') {
- this.current.set(login, { ms: null, falseStart: true });
- } else if (this.phase === 'go') {
- this.current.set(login, { ms: Math.max(0, this.clock.now() - this.goAt) });
- } else {
- return;
- }
- if (this.current.size === this.players.size && this.phase === 'go') {
- this.endRound();
- } else {
- this.emit();
- }
- }
-
- private nextRound() {
- this.round++;
- this.current.clear();
- this.phase = 'waiting';
- const delay = MIN_DELAY_MS + Math.floor(this.clock.random() * (MAX_DELAY_MS - MIN_DELAY_MS));
- this.schedule(() => this.go(), delay);
- this.emit();
- }
-
- private go() {
- this.phase = 'go';
- this.goAt = this.clock.now();
- if (this.current.size === this.players.size) {
- // Všichni odstartovali předčasně, není na co čekat
- this.endRound();
- return;
- }
- this.schedule(() => this.endRound(), CLICK_WINDOW_MS);
- this.emit();
- }
-
- private endRound() {
- for (const player of this.players.values()) {
- const result = this.current.get(player.login) ?? { ms: null };
- player.rounds.push(result);
- player.total += result.ms ?? PENALTY_MS;
- }
- this.current.clear();
- if (this.round >= ROUNDS) {
- this.finish();
- return;
- }
- this.phase = 'roundResult';
- this.schedule(() => this.nextRound(), ROUND_PAUSE_MS);
- this.emit();
- }
-
- private finish() {
- const ranked = [...this.players.values()].sort((a, b) => a.total - b.total);
- this.winner = ranked[0]?.login;
- this.loser = ranked.length > 1 ? ranked[ranked.length - 1].login : undefined;
- this.phase = 'finished';
- this.clearTimer();
- this.emit();
- }
-
- private cancel() {
- this.reset();
- this.emit();
- }
-
- private reset() {
- this.clearTimer();
- this.phase = 'idle';
- this.host = undefined;
- this.round = 0;
- this.players.clear();
- this.current.clear();
- this.loser = undefined;
- this.winner = undefined;
- }
-
- private schedule(fn: () => void, ms: number) {
- this.clearTimer();
- this.timer = this.clock.setTimeout(() => {
- this.timer = undefined;
- fn();
- }, ms);
- }
-
- private clearTimer() {
- if (this.timer !== undefined) {
- this.clock.clearTimeout(this.timer);
- this.timer = undefined;
- }
- }
-
- /** Rozešle stav všem klientům; výsledky kola se zveřejní až po jeho skončení. */
- private emit() {
- this.broadcast(this.getState());
- }
-}
-
-/** Zaregistruje websocket handlery reakčního duelu. */
-export function registerReactionDuel(io: Server) {
- const duel = new ReactionDuel(state => io.emit('duel:state', state), minPlayersForEnv(2));
-
- io.on('connection', socket => {
- const handle = (action: (login: string) => void) => gameHandler(socket, 'duel:error', action);
- socket.on('duel:sync', () => socket.emit('duel:state', duel.getState()));
- socket.on('duel:join', handle(login => duel.join(login)));
- socket.on('duel:leave', handle(login => duel.leave(login)));
- socket.on('duel:start', handle(login => duel.start(login)));
- socket.on('duel:click', handle(login => duel.click(login)));
- });
-}
diff --git a/server/src/snakeGame.ts b/server/src/snakeGame.ts
deleted file mode 100644
index bb7e29e..0000000
--- a/server/src/snakeGame.ts
+++ /dev/null
@@ -1,344 +0,0 @@
-import { Server } from "socket.io";
-import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
-
-/**
- * Multiplayerový had přes websocket.
- *
- * Server drží celou simulaci (tick smyčka, kolize, jídlo), klienti posílají jen směr.
- * Had umírá nárazem do zdi, do sebe nebo do jiného hada (čelní srážka zabije oba).
- * Pořadí: kdo přežije déle, je výš; při stejném okamžiku smrti rozhoduje délka.
- * Hra končí, když zbyde poslední živý had (sólo hra až smrtí), nebo po {@link MAX_TICKS}.
- *
- * Průběžné snímky chodí jen do místnosti {@link WATCH_ROOM} (klienti s otevřeným oknem),
- * ostatním jen změny lobby kvůli pozvánce. Stav je v paměti, předpokládá jednu repliku.
- */
-
-export const WIDTH = 32;
-export const HEIGHT = 20;
-export const TICK_MS = 120;
-/** Maximální délka hry (~3 minuty). */
-export const MAX_TICKS = Math.round(3 * 60 * 1000 / TICK_MS);
-export const COUNTDOWN_SECONDS = 3;
-export const START_LENGTH = 3;
-export const MAX_PLAYERS = 8;
-/** Neodstartované lobby se po této době zruší (ms). */
-export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
-/** Kolik změn směru si server pamatuje dopředu (rychlé zatáčky v jednom ticku). */
-const MAX_QUEUED_TURNS = 2;
-export const WATCH_ROOM = 'snake-watchers';
-
-export type Direction = 'up' | 'down' | 'left' | 'right';
-export type Cell = [number, number];
-export type SnakePhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
-
-export type SnakePlayer = {
- login: string,
- /** Index barvy hada (klient si mapuje na paletu). */
- color: number,
- /** Tělo od hlavy po ocas. */
- body: Cell[],
- alive: boolean,
- /** Počet snědených jídel. */
- score: number,
- /** Konečné umístění (jen ve fázi finished). */
- place?: number,
-};
-
-export type SnakeState = {
- phase: SnakePhase,
- host?: string,
- players: SnakePlayer[],
- food: Cell[],
- tick: number,
- maxTicks: number,
- /** Zbývající sekundy odpočtu (ve fázi countdown). */
- countdown?: number,
- width: number,
- height: number,
- minPlayers: number,
-};
-
-type InternalPlayer = SnakePlayer & {
- dir: Direction,
- queue: Direction[],
- deathTick?: number,
-};
-
-const DELTAS: Record = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
-const OPPOSITE: Record = { up: 'down', down: 'up', left: 'right', right: 'left' };
-const isDirection = (d: unknown): d is Direction => typeof d === 'string' && d in DELTAS;
-const key = ([x, y]: Cell) => `${x},${y}`;
-
-export class SnakeGame {
- private phase: SnakePhase = 'idle';
- private host?: string;
- private players = new Map();
- private food: Cell[] = [];
- private tickNo = 0;
- private countdown = 0;
- private timer: unknown;
-
- /**
- * @param broadcastAll stav pro všechny (změny lobby a fáze)
- * @param broadcastWatchers průběžné snímky jen pro klienty se otevřenou hrou
- */
- constructor(
- private readonly broadcastAll: (state: SnakeState) => void,
- private readonly broadcastWatchers: (state: SnakeState) => void,
- private readonly minPlayers = 1,
- private readonly clock: Clock = defaultClock,
- ) { }
-
- getState(): SnakeState {
- return {
- phase: this.phase,
- host: this.host,
- players: [...this.players.values()].map(p => ({
- login: p.login,
- color: p.color,
- body: p.body.map(c => [c[0], c[1]] as Cell),
- alive: p.alive,
- score: p.score,
- place: p.place,
- })),
- food: this.food.map(c => [c[0], c[1]] as Cell),
- tick: this.tickNo,
- maxTicks: MAX_TICKS,
- countdown: this.phase === 'countdown' ? this.countdown : undefined,
- width: WIDTH,
- height: HEIGHT,
- minPlayers: this.minPlayers,
- };
- }
-
- join(login: string) {
- if (this.phase === 'idle' || this.phase === 'finished') {
- this.reset();
- this.phase = 'lobby';
- this.host = login;
- this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
- } else if (this.phase !== 'lobby') {
- throw new GameError('Hra už běží, počkejte na další.');
- }
- if (!this.players.has(login)) {
- if (this.players.size >= MAX_PLAYERS) {
- throw new GameError('Lobby je plné.');
- }
- const used = new Set([...this.players.values()].map(p => p.color));
- let color = 0;
- while (used.has(color)) color++;
- this.players.set(login, { login, color, body: [], alive: true, score: 0, dir: 'right', queue: [] });
- }
- this.emitAll();
- }
-
- leave(login: string) {
- if (this.phase !== 'lobby' || !this.players.has(login)) {
- return;
- }
- this.players.delete(login);
- if (login === this.host || this.players.size === 0) {
- this.cancel();
- return;
- }
- this.emitAll();
- }
-
- start(login: string) {
- if (this.phase !== 'lobby') {
- throw new GameError('Není otevřené žádné lobby.');
- }
- if (login !== this.host) {
- throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
- }
- if (this.players.size < this.minPlayers) {
- throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
- }
- this.spawn();
- this.phase = 'countdown';
- this.countdown = COUNTDOWN_SECONDS;
- this.emitAll();
- this.schedule(() => this.countdownStep(), 1000);
- }
-
- /** Zařadí změnu směru; obrat o 180° a opakování stejného směru se ignorují. */
- turn(login: string, dir: unknown) {
- const p = this.players.get(login);
- if (!p || !p.alive || !isDirection(dir) || (this.phase !== 'playing' && this.phase !== 'countdown')) {
- return;
- }
- const last = p.queue.length ? p.queue[p.queue.length - 1] : p.dir;
- if (dir === last || dir === OPPOSITE[last] || p.queue.length >= MAX_QUEUED_TURNS) {
- return;
- }
- p.queue.push(dir);
- }
-
- /** Rozmístí hady do řádků, střídavě zleva doprava a zprava doleva. */
- private spawn() {
- const list = [...this.players.values()];
- list.forEach((p, i) => {
- const y = Math.floor((i + 1) * HEIGHT / (list.length + 1));
- const fromLeft = i % 2 === 0;
- const headX = fromLeft ? 2 + START_LENGTH : WIDTH - 3 - START_LENGTH;
- p.dir = fromLeft ? 'right' : 'left';
- p.body = Array.from({ length: START_LENGTH }, (_, k) => [fromLeft ? headX - k : headX + k, y] as Cell);
- p.alive = true;
- p.score = 0;
- p.queue = [];
- p.place = undefined;
- p.deathTick = undefined;
- });
- this.food = [];
- this.tickNo = 0;
- this.refillFood();
- }
-
- private countdownStep() {
- this.countdown--;
- if (this.countdown > 0) {
- this.emitAll();
- this.schedule(() => this.countdownStep(), 1000);
- return;
- }
- this.phase = 'playing';
- this.emitAll();
- this.schedule(() => this.step(), TICK_MS);
- }
-
- /** Jeden krok simulace. */
- private step() {
- this.tickNo++;
- const alive = [...this.players.values()].filter(p => p.alive);
- const foodKeys = new Set(this.food.map(key));
- const eaten = new Set();
-
- // Pohyb: nová hlava, ocas se odebere, pokud had nejí
- for (const p of alive) {
- const next = p.queue.shift();
- if (next) p.dir = next;
- const [dx, dy] = DELTAS[p.dir];
- const head: Cell = [p.body[0][0] + dx, p.body[0][1] + dy];
- p.body.unshift(head);
- if (foodKeys.has(key(head))) {
- eaten.add(key(head));
- p.score++;
- } else {
- p.body.pop();
- }
- }
-
- // Kolize se vyhodnotí až po pohybu všech, aby nezáleželo na pořadí hráčů
- const occupied = new Map();
- for (const p of alive) {
- for (const c of p.body) occupied.set(key(c), (occupied.get(key(c)) ?? 0) + 1);
- }
- for (const p of alive) {
- const [x, y] = p.body[0];
- const outside = x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT;
- if (outside || (occupied.get(key(p.body[0])) ?? 0) > 1) {
- p.alive = false;
- p.deathTick = this.tickNo;
- }
- }
- for (const p of alive) {
- if (!p.alive) p.body = [];
- }
-
- this.food = this.food.filter(c => !eaten.has(key(c)));
- this.refillFood();
-
- const stillAlive = alive.filter(p => p.alive).length;
- const multiplayer = this.players.size > 1;
- if (stillAlive === 0 || (multiplayer && stillAlive <= 1) || this.tickNo >= MAX_TICKS) {
- this.finish();
- return;
- }
- this.emitWatchers();
- this.schedule(() => this.step(), TICK_MS);
- }
-
- /** Na hrací ploše je tolik jídel, kolik je hráčů. */
- private refillFood() {
- const taken = new Set(this.food.map(key));
- for (const p of this.players.values()) p.body.forEach(c => taken.add(key(c)));
- const target = Math.max(1, this.players.size);
- let attempts = 0;
- while (this.food.length < target && attempts++ < 1000) {
- const cell: Cell = [Math.floor(this.clock.random() * WIDTH), Math.floor(this.clock.random() * HEIGHT)];
- if (!taken.has(key(cell))) {
- taken.add(key(cell));
- this.food.push(cell);
- }
- }
- }
-
- private finish() {
- const ranked = [...this.players.values()].sort((a, b) =>
- (b.deathTick ?? Infinity) - (a.deathTick ?? Infinity) || b.score - a.score);
- ranked.forEach((p, i) => { p.place = i + 1; });
- this.phase = 'finished';
- this.clearTimer();
- this.emitAll();
- }
-
- private cancel() {
- this.reset();
- this.emitAll();
- }
-
- private reset() {
- this.clearTimer();
- this.phase = 'idle';
- this.host = undefined;
- this.players.clear();
- this.food = [];
- this.tickNo = 0;
- }
-
- private schedule(fn: () => void, ms: number) {
- this.clearTimer();
- this.timer = this.clock.setTimeout(() => {
- this.timer = undefined;
- fn();
- }, ms);
- }
-
- private clearTimer() {
- if (this.timer !== undefined) {
- this.clock.clearTimeout(this.timer);
- this.timer = undefined;
- }
- }
-
- private emitAll() {
- this.broadcastAll(this.getState());
- }
-
- private emitWatchers() {
- this.broadcastWatchers(this.getState());
- }
-}
-
-/** Zaregistruje websocket handlery hada. */
-export function registerSnakeGame(io: Server) {
- const game = new SnakeGame(
- state => io.emit('snake:state', state),
- state => io.to(WATCH_ROOM).emit('snake:state', state),
- 1, // had se dá hrát i sólo
- );
-
- io.on('connection', socket => {
- const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'snake:error', action);
- socket.on('snake:sync', () => socket.emit('snake:state', game.getState()));
- socket.on('snake:watch', () => {
- socket.join(WATCH_ROOM);
- socket.emit('snake:state', game.getState());
- });
- socket.on('snake:unwatch', () => socket.leave(WATCH_ROOM));
- socket.on('snake:join', handle(login => game.join(login)));
- socket.on('snake:leave', handle(login => game.leave(login)));
- socket.on('snake:start', handle(login => game.start(login)));
- socket.on('snake:turn', handle((login, payload) => game.turn(login, (payload as { dir?: unknown })?.dir)));
- });
-}
diff --git a/server/src/tankGame.ts b/server/src/tankGame.ts
deleted file mode 100644
index e069938..0000000
--- a/server/src/tankGame.ts
+++ /dev/null
@@ -1,1426 +0,0 @@
-import { Server } from "socket.io";
-import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } from "./minigames";
-
-/**
- * Tanky pro více hráčů přes websocket.
- *
- * Server drží celou simulaci: pohyb tanků po mapě z dlaždic, střely (volitelně odrážené od zdí),
- * zničitelné bedny, terén (voda, bláto, porost) a bonusy. Klienti posílají jen stav ovládání
- * (jízda, otáčení, střelba), bod, kam míří myší (věž se otáčí nezávisle na korbě), a pokládání min.
- *
- * Režimy:
- * - `deathmatch` — hraje se na čas, po zničení se tank znovu objeví; vyhrává nejvíc bodů
- * (zničení soupeře +1, zničení sebe sama −1).
- * - `lastman` — každý má {@link LAST_MAN_LIVES} životy, vyhrává poslední, komu nějaký zbyl.
- * Oba režimy jdou hrát i ve dvou týmech (levá a pravá strana mapy); spoluhráči se navzájem nezraní,
- * a v hardcore variantě, kdy je aréna potemnělá a vidět je jen světlo kolem tanku (viz {@link LIGHT_RADIUS}).
- *
- * Zakladatel lobby může přidat boty. Průběžné snímky chodí jen do {@link WATCH_ROOM}.
- * Stav je v paměti, předpokládá jednu repliku.
- */
-
-export const TILE = 40;
-export const COLS = 28;
-export const ROWS = 17;
-export const FIELD_WIDTH = COLS * TILE;
-export const FIELD_HEIGHT = ROWS * TILE;
-export const TICK_MS = 33;
-export const TANK_RADIUS = 14;
-/** Rychlosti v jednotkách hřiště za sekundu, otáčení v rad/s. */
-export const TANK_SPEED = 130;
-export const TANK_REVERSE_SPEED = 85;
-export const TANK_TURN_SPEED = 3.2;
-/** Věž se otáčí nezávisle na korbě a rychleji. */
-export const TURRET_TURN_SPEED = 6.5;
-/** Zpomalení v blátě / sněhu / písku a v porostu. */
-export const MUD_FACTOR = 0.55;
-export const BULLET_SPEED = 320;
-export const BULLET_RADIUS = 4;
-export const BULLET_LIFETIME_MS = 3500;
-/** Kolikrát se střela může odrazit od zdi, než zanikne (jen se zapnutým odrážením). */
-export const BULLET_MAX_BOUNCES = 2;
-export const FIRE_COOLDOWN_MS = 450;
-/** Max. počet střel jednoho tanku ve vzduchu (s trojitou střelou trojnásobek). */
-export const MAX_BULLETS = 4;
-export const RESPAWN_MS = 2000;
-/** Ochrana po (znovu)zrození — střely tankem proletí. */
-export const SPAWN_PROTECTION_MS = 1500;
-export const DEATHMATCH_MS = 3 * 60 * 1000;
-export const LAST_MAN_LIVES = 3;
-/** Pojistka, aby hra na přežití netrvala donekonečna. */
-export const LAST_MAN_MAX_MS = 5 * 60 * 1000;
-export const PICKUP_INTERVAL_MS = 6000;
-export const MAX_PICKUPS = 3;
-export const PICKUP_RADIUS = 14;
-export const SHIELD_MS = 15000;
-export const TRIPLE_SHOT_MS = 10000;
-export const TURBO_MS = 8000;
-export const TURBO_FACTOR = 1.6;
-export const GHOST_MS = 8000;
-export const HOMING_MS = 10000;
-/** Jak rychle se naváděná střela stáčí k cíli (rad/s) a z jaké dálky cíl hledá. */
-export const HOMING_TURN_SPEED = 3;
-export const HOMING_RANGE = 300;
-/** Kolik raket / min dá jeden bonus a kolik jich jde mít nejvýš. */
-export const ROCKET_AMMO = 3;
-export const MINE_AMMO = 3;
-export const MAX_AMMO = 6;
-export const ROCKET_SPEED = 260;
-/** Poloměr výbuchu rakety a miny. */
-export const BLAST_RADIUS = 55;
-/** Mina se aktivuje až chvíli po položení. */
-export const MINE_ARM_MS = 1000;
-export const MINE_TRIGGER_RADIUS = TANK_RADIUS + 6;
-/** Na dálku, ze které je vidět i neviditelný tank. */
-export const REVEAL_DISTANCE = 70;
-/**
- * Hardcore (tma): hráč vidí jen kruh kolem tanku a kužel reflektoru ve směru věže (poloviční úhel v rad);
- * světlo zastaví zdi a bedny. Ve hře v týmech je vidění společné. Tmu kreslí klient, server ji uplatňuje u botů.
- */
-export const LIGHT_RADIUS = 130;
-export const SPOTLIGHT_RANGE = 300;
-export const SPOTLIGHT_HALF_ANGLE = 0.45;
-/** Jak dlouho zůstává na mapě hořící vrak a výbuch (ms). */
-export const WRECK_MS = 8000;
-export const EXPLOSION_MS = 600;
-/** Úhel krajních střel trojité střely (rad). */
-const TRIPLE_SPREAD = 0.22;
-export const COUNTDOWN_SECONDS = 3;
-export const MAX_PLAYERS = 6;
-export const MAX_TEAM_SIZE = MAX_PLAYERS / 2;
-export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
-export const WATCH_ROOM = 'tanks-watchers';
-
-export type TankPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
-export type TankMode = 'deathmatch' | 'lastman';
-export type BotLevel = 'easy' | 'medium' | 'hard';
-export type PickupType = 'shield' | 'triple' | 'rocket' | 'turbo' | 'ghost' | 'homing' | 'mine' | 'heart';
-export type Biome = 'city' | 'forest' | 'desert' | 'winter' | 'swamp';
-export type Team = 0 | 1;
-
-type BotProfile = {
- label: string,
- /** Jak často bot přehodnocuje cíl a cestu (ms). */
- reactMs: number,
- /** Max. odchylka míření (± rad). */
- aimError: number,
- /** Jak přesně musí mířit, aby vystřelil (rad). */
- fireTolerance: number,
-};
-
-export const BOT_PROFILES: Record = {
- easy: { label: 'Lehký', reactMs: 450, aimError: 0.3, fireTolerance: 0.2 },
- medium: { label: 'Střední', reactMs: 250, aimError: 0.12, fireTolerance: 0.12 },
- hard: { label: 'Těžký', reactMs: 120, aimError: 0.04, fireTolerance: 0.07 },
-};
-
-/**
- * Mapy: levá polovina řádku, pravá se doplní zrcadlově (v týmech hraje každý tým na své půlce).
- * `#` zeď / skála / strom, `+` bedna (zničitelná), `~` voda (neprůjezdná, ale přestřelitelná),
- * `:` bláto / sníh / písek, `%` porost — kapradí, suchá tráva, keříky, rákos (obojí zpomaluje),
- * `S` místo zrození, `.` volno.
- * Mimo hřiště je zeď.
- */
-const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] = [
- {
- id: 'arena',
- name: 'Aréna',
- biome: 'city',
- half: [
- "S..........++.",
- "..............",
- "..##..........",
- "..#.....#.....",
- "........#..+..",
- ".....+........",
- "....##....#...",
- "..........#...",
- "S......+......",
- "..........#...",
- "....##....#...",
- ".....+........",
- "........#..+..",
- "..#.....#.....",
- "..##..........",
- "..............",
- "S..........++.",
- ],
- },
- {
- id: 'maze',
- name: 'Bludiště',
- biome: 'city',
- half: [
- "S...#.........",
- "..#.#.####....",
- "..#......#..+.",
- "..####.#.#....",
- ".......#...##.",
- "##.+.###......",
- "....+.....+...",
- ".#.......##...",
- "S.#####...+...",
- ".#.......##...",
- "....+.....+...",
- "##.+.###......",
- ".......#...##.",
- "..####.#.#....",
- "..#......#..+.",
- "..#.#.####....",
- "S...#.........",
- ],
- },
- {
- id: 'warehouse',
- name: 'Sklad',
- biome: 'city',
- half: [
- "S.....+.......",
- ".++.+++.++..+.",
- ".++.....++....",
- "....##......+.",
- "+.+.##.+.+....",
- "....+......##.",
- ".#..+.###.....",
- ".#......#..+..",
- "S...+...#.....",
- ".#......#..+..",
- ".#..+.###.....",
- "....+......##.",
- "+.+.##.+.+....",
- "....##......+.",
- ".++.....++....",
- ".++.+++.++..+.",
- "S.....+.......",
- ],
- },
- {
- id: 'forest',
- name: 'Les',
- biome: 'forest',
- half: [
- "S..%%...#..#..",
- "..%%%..##...%.",
- "......%%...%%.",
- "#..#.......%..",
- "##...%%..#...#",
- "....%%%..##...",
- ".#......%%..#.",
- "~~.~~~~.~~~~.~",
- "S.%%%.....+...",
- "..%%..##..%%..",
- "#.......#..%..",
- "##..%%.....#..",
- "...%%%..##....",
- ".#.....%%...#.",
- "...##...%..~~.",
- "..%%..#....~~.",
- "S......+......",
- ],
- },
- {
- id: 'desert',
- name: 'Poušť',
- biome: 'desert',
- half: [
- "S.....##......",
- "...::.....#...",
- "..::::..#...::",
- ".......##.....",
- "#..#......~~..",
- "##..+....~~~..",
- "......#..~~~.#",
- "..#...#...~~..",
- "S.....#..+....",
- "..#...#...~~..",
- "......#..~~~.#",
- "##..+....~~~..",
- "#..#......~~..",
- ".......##.....",
- "..::::..#...::",
- "...::.....#...",
- "S.....##......",
- ],
- },
- {
- id: 'winter',
- name: 'Zima',
- biome: 'winter',
- half: [
- "S...%%....#...",
- ".::.%%..::..#.",
- ".::.....::....",
- "....##.....%%.",
- "%%..##..~~....",
- "%%......~~..#.",
- "...#::......#.",
- "....::..##....",
- "S..+::..##.+..",
- "....::........",
- "...#::......#.",
- "%%......~~..#.",
- "%%..##..~~....",
- "....##.....%%.",
- ".::.....::....",
- ".::.%%..::..#.",
- "S...%%....#...",
- ],
- },
- {
- id: 'swamp',
- name: 'Bažina',
- biome: 'swamp',
- half: [
- "S..::..%%.....",
- "..:::~~%%.::#.",
- ".::.~~~...::..",
- ".....~..#.....",
- "%%.......~~.#.",
- "%%%.::::.~~...",
- "..+.::::....%%",
- ".#........#.%%",
- "S...::....+...",
- ".#........#...",
- "..+.::::....%%",
- "%%%.::::.~~...",
- "%%.......~~.#.",
- ".....~..#.....",
- ".::.~~~...::..",
- "..:::~~%%.::#.",
- "S..::..%%.....",
- ],
- },
-];
-
-export const MAPS = MAP_HALVES.map(m => ({
- id: m.id,
- name: m.name,
- biome: m.biome,
- rows: m.half.map(r => r + [...r].reverse().join('')),
-}));
-
-export type TankPlayer = {
- login: string,
- /** Index barvy (klient si mapuje na paletu; v týmech index v rámci týmu). */
- color: number,
- team?: Team,
- x: number,
- y: number,
- /** Natočení v radiánech, 0 = doprava, kladně po směru hodinových ručiček. */
- angle: number,
- /** Natočení věže (směr střelby) v radiánech, nezávislé na korbě. */
- turret: number,
- alive: boolean,
- kills: number,
- deaths: number,
- score: number,
- /** Zbývající životy (jen režim lastman). */
- lives?: number,
- shield: boolean,
- triple: boolean,
- turbo: boolean,
- ghost: boolean,
- homing: boolean,
- rockets: number,
- mines: number,
- /** Ochrana po zrození. */
- protected: boolean,
- /** Počítadlo výstřelů — klient podle změny kreslí záblesk. */
- shots: number,
- /** Za kolik ms se tank znovu objeví (jen zničený tank, který ještě může hrát). */
- respawnIn?: number,
- bot?: BotLevel,
- /** Konečné umístění (jen ve fázi finished). */
- place?: number,
-};
-
-export type TankBullet = { x: number, y: number, color: number, team?: Team, angle: number, rocket: boolean };
-export type TankPickup = { x: number, y: number, type: PickupType };
-export type TankMine = { x: number, y: number, owner: string, team?: Team, color: number, armed: boolean };
-export type TankWreck = { x: number, y: number, angle: number, turret: number, color: number, team?: Team, age: number };
-export type TankExplosion = { x: number, y: number, r: number, age: number };
-
-export type TankState = {
- phase: TankPhase,
- host?: string,
- mode: TankMode,
- teams: boolean,
- hardcore: boolean,
- /** Střely se odráží od zdí (jinak na zdi zaniknou). */
- bounce: boolean,
- /** Zvolená mapa, v lobby může být 'random'. */
- mapId: string,
- biome: Biome,
- maps: { id: string, name: string, biome: Biome }[],
- /** Aktuální dlaždice mapy (bedny mizí). */
- tiles: string[],
- players: TankPlayer[],
- bullets: TankBullet[],
- pickups: TankPickup[],
- mines: TankMine[],
- wrecks: TankWreck[],
- explosions: TankExplosion[],
- /** Body týmů (na čas součet bodů, na přežití součet životů). */
- teamScores?: [number, number],
- /** Vítězný tým po skončení, -1 = remíza. */
- winnerTeam?: number,
- countdown?: number,
- timeLeftMs: number,
- width: number,
- height: number,
- tile: number,
- minPlayers: number,
- maxPlayers: number,
-};
-
-/** Nastavení hry z lobby (hodnoty přichází od klienta, ověřují se v {@link TankGame.configure}). */
-export type TankOptions = { mode?: unknown, mapId?: unknown, teams?: unknown, hardcore?: unknown, bounce?: unknown };
-
-/** Tankové ovládání (`drive` + `turn`), nebo směrové — `dir` je požadovaný směr jízdy na hřišti. */
-type Input = { drive: number, turn: number, fire: boolean, dir?: [number, number] };
-
-type InternalPlayer = TankPlayer & {
- input: Input,
- /** Bod na hřišti, kam hráč míří myší; bez něj věž sleduje směr korby. */
- aim?: [number, number],
- nextFireAt: number,
- respawnAt?: number,
- protectedUntil: number,
- shieldUntil: number,
- tripleUntil: number,
- turboUntil: number,
- ghostUntil: number,
- homingUntil: number,
- /** Kdy přišel o poslední život (lastman), pro pořadí. */
- eliminatedAt?: number,
- // Stav bota
- botThinkAt?: number,
- botAngle?: number,
- botTurret?: number,
- botFire?: boolean,
- botLastPos?: [number, number],
- botReverseUntil?: number,
- /** Kam bot ve tmě prohledává mapu a do kdy. */
- botRoam?: [number, number],
- botRoamUntil?: number,
-};
-
-type Bullet = {
- owner: string, team?: Team, color: number,
- x: number, y: number, vx: number, vy: number,
- bornAt: number, bounces: number, rocket: boolean, homing: boolean,
-};
-type Mine = { owner: string, team?: Team, color: number, x: number, y: number, armedAt: number };
-type Wreck = Omit & { at: number };
-type Explosion = Omit & { at: number };
-
-/** Výchozí stav efektů a munice (při startu, zrození i zničení). */
-const NO_EFFECTS = {
- shieldUntil: 0, tripleUntil: 0, turboUntil: 0, ghostUntil: 0, homingUntil: 0, rockets: 0, mines: 0,
-};
-
-const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
-/** Rozdíl úhlů v rozsahu (−π, π]. */
-const angleDiff = (a: number, b: number) => {
- let d = (a - b) % (Math.PI * 2);
- if (d > Math.PI) d -= Math.PI * 2;
- if (d <= -Math.PI) d += Math.PI * 2;
- return d;
-};
-const round2 = (v: number) => Math.round(v * 100) / 100;
-const isMode = (m: unknown): m is TankMode => m === 'deathmatch' || m === 'lastman';
-const isBotLevel = (l: unknown): l is BotLevel => typeof l === 'string' && l in BOT_PROFILES;
-const axis = (v: unknown) => typeof v === 'number' && Number.isFinite(v) ? clamp(Math.round(v), -1, 1) : 0;
-
-export class TankGame {
- private phase: TankPhase = 'idle';
- private host?: string;
- private mode: TankMode = 'lastman';
- private teams = false;
- private hardcore = false;
- private bounce = false;
- private mapId = 'random';
- private tiles: string[][] = [];
- private players = new Map();
- private bullets: Bullet[] = [];
- private pickups: TankPickup[] = [];
- private mines: Mine[] = [];
- private wrecks: Wreck[] = [];
- private explosions: Explosion[] = [];
- private winnerTeam?: number;
- private countdown = 0;
- private startedAt = 0;
- private nextPickupAt = 0;
- private lastTick = 0;
- private timer: unknown;
-
- constructor(
- private readonly broadcastAll: (state: TankState) => void,
- private readonly broadcastWatchers: (state: TankState) => void,
- private readonly minPlayers = 2,
- private readonly clock: Clock = defaultClock,
- ) { }
-
- getState(): TankState {
- const now = this.clock.now();
- const limit = this.mode === 'deathmatch' ? DEATHMATCH_MS : LAST_MAN_MAX_MS;
- const map = MAPS.find(m => m.id === this.mapId) ?? MAPS[0];
- return {
- phase: this.phase,
- host: this.host,
- mode: this.mode,
- teams: this.teams,
- hardcore: this.hardcore,
- bounce: this.bounce,
- mapId: this.mapId,
- biome: map.biome,
- maps: MAPS.map(m => ({ id: m.id, name: m.name, biome: m.biome })),
- tiles: this.tiles.length ? this.tiles.map(r => r.join('')) : map.rows,
- players: [...this.players.values()].map(p => ({
- login: p.login,
- color: p.color,
- team: p.team,
- x: Math.round(p.x),
- y: Math.round(p.y),
- angle: round2(p.angle),
- turret: round2(p.turret),
- alive: p.alive,
- kills: p.kills,
- deaths: p.deaths,
- score: p.score,
- lives: p.lives,
- shield: p.alive && p.shieldUntil > now,
- triple: p.alive && p.tripleUntil > now,
- turbo: p.alive && p.turboUntil > now,
- ghost: p.alive && p.ghostUntil > now,
- homing: p.alive && p.homingUntil > now,
- rockets: p.rockets,
- mines: p.mines,
- protected: p.alive && p.protectedUntil > now,
- shots: p.shots,
- respawnIn: !p.alive && p.respawnAt !== undefined ? Math.max(0, p.respawnAt - now) : undefined,
- bot: p.bot,
- place: p.place,
- })),
- bullets: this.bullets.map(b => ({
- x: Math.round(b.x), y: Math.round(b.y), color: b.color, team: b.team,
- angle: round2(Math.atan2(b.vy, b.vx)), rocket: b.rocket,
- })),
- pickups: this.pickups.map(p => ({ ...p })),
- mines: this.mines.map(m => ({
- x: Math.round(m.x), y: Math.round(m.y), owner: m.owner, team: m.team, color: m.color, armed: now >= m.armedAt,
- })),
- wrecks: this.wrecks.map(({ at, ...w }) => ({ ...w, x: Math.round(w.x), y: Math.round(w.y), age: now - at })),
- explosions: this.explosions.map(({ at, ...e }) => ({ ...e, x: Math.round(e.x), y: Math.round(e.y), age: now - at })),
- teamScores: this.teams ? this.teamScores() : undefined,
- winnerTeam: this.winnerTeam,
- countdown: this.phase === 'countdown' ? this.countdown : undefined,
- timeLeftMs: this.phase === 'playing' ? Math.max(0, limit - (now - this.startedAt)) : limit,
- width: FIELD_WIDTH,
- height: FIELD_HEIGHT,
- tile: TILE,
- minPlayers: this.minPlayers,
- maxPlayers: MAX_PLAYERS,
- };
- }
-
- join(login: string) {
- if (this.phase === 'idle' || this.phase === 'finished') {
- this.reset();
- this.phase = 'lobby';
- this.host = login;
- this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
- } else if (this.phase !== 'lobby') {
- throw new GameError('Hra už běží, počkejte na další.');
- }
- if (this.players.has(login)) return;
- this.addPlayer(login);
- this.emitAll();
- }
-
- leave(login: string) {
- if (this.phase !== 'lobby' || !this.players.has(login)) return;
- if (login === this.host) {
- this.cancel();
- return;
- }
- this.players.delete(login);
- this.recolor();
- this.emitAll();
- }
-
- /** Zakladatel nastaví režim, mapu, hru v týmech, odrážení střel a hardcore (tmu); chybějící volby se nemění. */
- configure(login: string, options: TankOptions) {
- const { mode, mapId, teams, hardcore, bounce } = options;
- this.requireHostInLobby(login);
- if (mode !== undefined) {
- if (!isMode(mode)) throw new GameError('Neznámý režim.');
- this.mode = mode;
- }
- if (mapId !== undefined) {
- if (mapId !== 'random' && !MAPS.some(m => m.id === mapId)) throw new GameError('Neznámá mapa.');
- this.mapId = mapId as string;
- }
- if (teams !== undefined) {
- this.teams = teams === true;
- // Rozdělení střídavě do týmů v pořadí příchodu
- [...this.players.values()].forEach((p, i) => { p.team = this.teams ? (i % 2) as Team : undefined; });
- this.recolor();
- }
- if (hardcore !== undefined) {
- this.hardcore = hardcore === true;
- }
- if (bounce !== undefined) {
- this.bounce = bounce === true;
- }
- this.emitAll();
- }
-
- /** Přesun do druhého týmu — sám sebe může přesunout každý, ostatní (i boty) zakladatel. */
- switchTeam(login: string, target: unknown) {
- if (this.phase !== 'lobby') throw new GameError('Není otevřené žádné lobby.');
- if (!this.teams) throw new GameError('Nehraje se v týmech.');
- const who = typeof target === 'string' ? target : login;
- if (who !== login && login !== this.host) throw new GameError('Ostatní hráče může přesouvat jen zakladatel.');
- const p = this.players.get(who);
- if (!p) return;
- const to: Team = p.team === 0 ? 1 : 0;
- if (this.teamSize(to) >= MAX_TEAM_SIZE) throw new GameError('Druhý tým je plný.');
- p.team = to;
- this.recolor();
- this.emitAll();
- }
-
- addBot(login: string, level: unknown) {
- this.requireHostInLobby(login);
- if (!isBotLevel(level)) throw new GameError('Neznámá obtížnost bota.');
- let n = 1;
- while ([...this.players.keys()].some(l => l.startsWith(`🤖 Bot ${n} `))) n++;
- this.addPlayer(`🤖 Bot ${n} (${BOT_PROFILES[level].label})`, level);
- this.emitAll();
- }
-
- removeBots(login: string) {
- if (this.phase !== 'lobby' || login !== this.host) return;
- for (const [l, p] of this.players) {
- if (p.bot) this.players.delete(l);
- }
- this.recolor();
- this.emitAll();
- }
-
- start(login: string) {
- this.requireHostInLobby(login);
- if (this.players.size < this.minPlayers) {
- throw new GameError(`Ke hře jsou potřeba alespoň ${this.minPlayers} tanky (boti se počítají).`);
- }
- if (this.teams && this.minPlayers > 1 && (this.teamSize(0) === 0 || this.teamSize(1) === 0)) {
- throw new GameError('V obou týmech musí být alespoň jeden tank.');
- }
- const map = this.mapId === 'random'
- ? MAPS[Math.floor(this.clock.random() * MAPS.length)]
- : MAPS.find(m => m.id === this.mapId)!;
- this.mapId = map.id;
- this.tiles = map.rows.map(r => [...r]);
- this.bullets = [];
- this.pickups = [];
- this.mines = [];
- this.wrecks = [];
- this.explosions = [];
- this.winnerTeam = undefined;
- // Rozmístění po spawnech v náhodném pořadí; týmy každý na své půlce
- const order = [...this.players.values()].sort(() => this.clock.random() - 0.5);
- const used = new Map();
- order.forEach(p => {
- const spawns = this.spawnPoints(p.team);
- const key = String(p.team);
- const i = used.get(key) ?? 0;
- used.set(key, i + 1);
- const [x, y] = spawns[i % spawns.length];
- const angle = x < FIELD_WIDTH / 2 ? 0 : Math.PI;
- Object.assign(p, {
- x, y, angle, turret: angle, aim: undefined,
- alive: true, kills: 0, deaths: 0, score: 0, shots: 0,
- lives: this.mode === 'lastman' ? LAST_MAN_LIVES : undefined,
- place: undefined, respawnAt: undefined, eliminatedAt: undefined,
- input: { drive: 0, turn: 0, fire: false },
- nextFireAt: 0, protectedUntil: 0, ...NO_EFFECTS,
- botThinkAt: undefined, botAngle: undefined, botTurret: undefined, botFire: false, botLastPos: undefined, botReverseUntil: undefined,
- });
- });
- this.phase = 'countdown';
- this.countdown = COUNTDOWN_SECONDS;
- this.emitAll();
- this.schedule(() => this.countdownStep(), 1000);
- }
-
- /**
- * Stav ovládání hráče: drive −1/0/1 (couvání/stát/jízda), turn −1/0/1 (doleva/rovně/doprava), fire.
- * Směrové ovládání místo drive/turn posílá dx, dy −1/0/1 — tank se natočí a jede tím směrem.
- */
- input(login: string, payload: unknown) {
- const p = this.players.get(login);
- if (!p || p.bot) return;
- const data = (payload ?? {}) as { drive?: unknown, turn?: unknown, fire?: unknown, dx?: unknown, dy?: unknown };
- const fire = data.fire === true;
- p.input = 'dx' in data || 'dy' in data
- ? { drive: 0, turn: 0, fire, dir: [axis(data.dx), axis(data.dy)] }
- : { drive: axis(data.drive), turn: axis(data.turn), fire };
- }
-
- /** Míření myší: bod na hřišti, kam se má věž natočit; `null` vrátí věž do směru jízdy. */
- aimAt(login: string, payload: unknown) {
- const p = this.players.get(login);
- if (!p || p.bot) return;
- const { x, y } = (payload ?? {}) as { x?: unknown, y?: unknown };
- if (typeof x === 'number' && typeof y === 'number' && Number.isFinite(x) && Number.isFinite(y)) {
- p.aim = [clamp(x, 0, FIELD_WIDTH), clamp(y, 0, FIELD_HEIGHT)];
- } else {
- p.aim = undefined;
- }
- }
-
- /** Položí minu za tank (pokud nějakou má). */
- dropMine(login: string) {
- const p = this.players.get(login);
- if (!p || !p.alive || p.mines <= 0 || this.phase !== 'playing') return;
- const back = TANK_RADIUS + 6;
- let x = p.x - Math.cos(p.angle) * back;
- let y = p.y - Math.sin(p.angle) * back;
- if (this.tankSolidAt(x, y)) {
- x = p.x;
- y = p.y;
- }
- p.mines--;
- this.mines.push({ owner: p.login, team: p.team, color: p.color, x, y, armedAt: this.clock.now() + MINE_ARM_MS });
- }
-
- private requireHostInLobby(login: string) {
- if (this.phase !== 'lobby') throw new GameError('Není otevřené žádné lobby.');
- if (login !== this.host) throw new GameError('To může jen ten, kdo lobby založil.');
- }
-
- private addPlayer(login: string, bot?: BotLevel) {
- if (this.players.size >= MAX_PLAYERS) throw new GameError('Lobby je plné.');
- const team: Team | undefined = this.teams ? (this.teamSize(1) < this.teamSize(0) ? 1 : 0) : undefined;
- this.players.set(login, {
- login, color: 0, team, x: 0, y: 0, angle: 0, turret: 0, alive: true, kills: 0, deaths: 0, score: 0,
- shield: false, triple: false, turbo: false, ghost: false, homing: false, protected: false,
- shots: 0, bot,
- input: { drive: 0, turn: 0, fire: false }, nextFireAt: 0, protectedUntil: 0, ...NO_EFFECTS,
- });
- this.recolor();
- }
-
- private teamSize(team: Team) {
- return [...this.players.values()].filter(p => p.team === team).length;
- }
-
- /** Barvy podle pořadí — v týmech pořadí v rámci týmu (klient má pro každý tým vlastní paletu). */
- private recolor() {
- const counts = [0, 0, 0];
- for (const p of this.players.values()) {
- const slot = p.team ?? 2;
- p.color = counts[slot]++;
- }
- }
-
- private isEnemy(a: { login: string, team?: Team }, b: { login: string, team?: Team }) {
- return a.login !== b.login && (!this.teams || a.team !== b.team);
- }
-
- /** Místa zrození; pro tým jen na jeho půlce mapy (tým 0 vlevo). */
- private spawnPoints(team?: Team): [number, number][] {
- const points: [number, number][] = [];
- this.tiles.forEach((row, r) => row.forEach((c, col) => {
- if (c === 'S') points.push([(col + 0.5) * TILE, (r + 0.5) * TILE]);
- }));
- if (team === undefined || !this.teams) return points;
- return points.filter(([x]) => (x < FIELD_WIDTH / 2) === (team === 0));
- }
-
- private countdownStep() {
- this.countdown--;
- if (this.countdown > 0) {
- this.emitAll();
- this.schedule(() => this.countdownStep(), 1000);
- return;
- }
- const now = this.clock.now();
- this.phase = 'playing';
- this.startedAt = now;
- this.lastTick = now;
- this.nextPickupAt = now + PICKUP_INTERVAL_MS;
- for (const p of this.players.values()) p.protectedUntil = now + SPAWN_PROTECTION_MS;
- this.emitAll();
- this.schedule(() => this.step(), TICK_MS);
- }
-
- /** Jeden krok simulace. */
- private step() {
- const now = this.clock.now();
- // Skutečně uplynulý čas, ať zpožděný tick nezpomalí hru (max. 100 ms proti skokům)
- const dt = Math.min(0.1, (now - this.lastTick) / 1000);
- this.lastTick = now;
-
- for (const p of this.players.values()) {
- if (!p.alive) {
- if (p.respawnAt !== undefined && now >= p.respawnAt) this.respawn(p, now);
- continue;
- }
- if (p.bot) this.driveBot(p, now, dt);
- this.moveTank(p, dt, now);
- this.rotateTurret(p, dt);
- this.collectPickups(p, now);
- if (p.input.fire && now >= p.nextFireAt) this.fire(p, now);
- }
- this.moveBullets(dt, now);
- this.triggerMines(now);
- this.spawnPickup(now);
- this.wrecks = this.wrecks.filter(w => now - w.at < WRECK_MS);
- this.explosions = this.explosions.filter(e => now - e.at < EXPLOSION_MS);
-
- if (this.isOver(now)) {
- this.finish();
- return;
- }
- this.emitWatchers();
- this.schedule(() => this.step(), TICK_MS);
- }
-
- private moveTank(p: InternalPlayer, dt: number, now: number) {
- let drive = p.input.drive;
- if (p.input.dir) {
- const [dx, dy] = p.input.dir;
- if (!dx && !dy) return;
- // Korba se rovnou otočí do zvoleného směru a tank jede
- p.angle = Math.atan2(dy, dx);
- drive = 1;
- } else {
- p.angle = (p.angle + p.input.turn * TANK_TURN_SPEED * dt) % (Math.PI * 2);
- }
- if (!drive) return;
- let speed = drive > 0 ? TANK_SPEED : TANK_REVERSE_SPEED;
- if (p.turboUntil > now) speed *= TURBO_FACTOR;
- const ground = this.tileAtPos(p.x, p.y);
- if (ground === ':' || ground === '%') speed *= MUD_FACTOR;
- const dist = drive * speed * dt;
- // Po osách zvlášť, aby tank klouzal podél zdí
- const nx = p.x + Math.cos(p.angle) * dist;
- if (!this.tankBlocked(p, nx, p.y)) p.x = nx;
- const ny = p.y + Math.sin(p.angle) * dist;
- if (!this.tankBlocked(p, p.x, ny)) p.y = ny;
- }
-
- /** Věž se dotáčí k cíli (myš, bot), jinak do směru jízdy. */
- private rotateTurret(p: InternalPlayer, dt: number) {
- const goal = p.bot
- ? p.botTurret ?? p.angle
- : p.aim ? Math.atan2(p.aim[1] - p.y, p.aim[0] - p.x) : p.angle;
- const step = TURRET_TURN_SPEED * dt;
- p.turret = (p.turret + clamp(angleDiff(goal, p.turret), -step, step)) % (Math.PI * 2);
- }
-
- private tankBlocked(p: InternalPlayer, x: number, y: number) {
- if (this.circleHitsTiles(x, y, TANK_RADIUS, true)) return true;
- for (const o of this.players.values()) {
- if (o === p || !o.alive) continue;
- const overlapNow = Math.hypot(o.x - p.x, o.y - p.y) < TANK_RADIUS * 2;
- // Překrývající se tanky (po zrození) se smí rozjet od sebe
- if (Math.hypot(o.x - x, o.y - y) < TANK_RADIUS * 2 && !overlapNow) return true;
- }
- return false;
- }
-
- /** Zda kruh zasahuje do pevné dlaždice; pro tanky je pevná i voda. */
- private circleHitsTiles(x: number, y: number, r: number, forTank: boolean) {
- if (x - r < 0 || y - r < 0 || x + r > FIELD_WIDTH || y + r > FIELD_HEIGHT) return true;
- for (let row = Math.floor((y - r) / TILE); row <= Math.floor((y + r) / TILE); row++) {
- for (let col = Math.floor((x - r) / TILE); col <= Math.floor((x + r) / TILE); col++) {
- if (!(forTank ? this.tankSolidTile(col, row) : this.solidTile(col, row))) continue;
- const cx = clamp(x, col * TILE, (col + 1) * TILE);
- const cy = clamp(y, row * TILE, (row + 1) * TILE);
- if ((x - cx) ** 2 + (y - cy) ** 2 < r * r) return true;
- }
- }
- return false;
- }
-
- private tileAt(col: number, row: number) {
- if (col < 0 || row < 0 || col >= COLS || row >= ROWS) return '#';
- return this.tiles[row][col];
- }
-
- private tileAtPos(x: number, y: number) {
- return this.tiles.length ? this.tileAt(Math.floor(x / TILE), Math.floor(y / TILE)) : '.';
- }
-
- /** Pevné pro střely (zeď, bedna). */
- private solidTile(col: number, row: number) {
- const t = this.tileAt(col, row);
- return t === '#' || t === '+';
- }
-
- /** Neprůjezdné pro tanky (navíc voda). */
- private tankSolidTile(col: number, row: number) {
- return this.solidTile(col, row) || this.tileAt(col, row) === '~';
- }
-
- private solidAt(x: number, y: number) {
- return this.solidTile(Math.floor(x / TILE), Math.floor(y / TILE));
- }
-
- private tankSolidAt(x: number, y: number) {
- return this.tankSolidTile(Math.floor(x / TILE), Math.floor(y / TILE));
- }
-
- private breakCrate(x: number, y: number) {
- const col = Math.floor(x / TILE);
- const row = Math.floor(y / TILE);
- if (this.tileAt(col, row) !== '+') return false;
- this.tiles[row][col] = '.';
- return true;
- }
-
- private fire(p: InternalPlayer, now: number) {
- const triple = p.tripleUntil > now;
- const own = this.bullets.filter(b => b.owner === p.login).length;
- if (own >= (triple ? MAX_BULLETS * 3 : MAX_BULLETS)) return;
- const rocket = p.rockets > 0;
- const cooldown = rocket ? FIRE_COOLDOWN_MS * 1.5 : FIRE_COOLDOWN_MS;
- const offset = TANK_RADIUS + BULLET_RADIUS + 2;
- const sx = p.x + Math.cos(p.turret) * offset;
- const sy = p.y + Math.sin(p.turret) * offset;
- if (this.solidAt(sx, sy)) {
- // Hlaveň v bedně ji rozbije rovnou (raketa vybuchne), do zdi se nestřílí (jinak by šlo pálit skrz)
- if (this.tileAtPos(sx, sy) !== '+') return;
- p.nextFireAt = now + cooldown;
- p.shots++;
- if (rocket) {
- p.rockets--;
- this.explode(sx, sy, p.login, p.team, now);
- } else {
- this.breakCrate(sx, sy);
- }
- return;
- }
- p.nextFireAt = now + cooldown;
- p.shots++;
- if (rocket) p.rockets--;
- const speed = rocket ? ROCKET_SPEED : BULLET_SPEED;
- for (const a of triple ? [-TRIPLE_SPREAD, 0, TRIPLE_SPREAD] : [0]) {
- const angle = p.turret + a;
- this.bullets.push({
- owner: p.login, team: p.team, color: p.color, x: sx, y: sy,
- vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
- bornAt: now, bounces: 0, rocket, homing: p.homingUntil > now,
- });
- }
- }
-
- private moveBullets(dt: number, now: number) {
- // Kopie — výbuch rakety může během pohybu odstranit i jiné střely nebo přidat výbuchy
- for (const b of [...this.bullets]) {
- if (!this.bullets.includes(b)) continue;
- let alive = now - b.bornAt < BULLET_LIFETIME_MS;
- if (alive) {
- if (b.homing) this.steerHoming(b, dt);
- alive = this.moveBullet(b, dt, now);
- } else if (b.rocket) {
- this.explode(b.x, b.y, b.owner, b.team, now);
- }
- if (!alive) this.bullets = this.bullets.filter(x => x !== b);
- }
- }
-
- /** Naváděná střela se stáčí k nejbližšímu viditelnému soupeři v dosahu. */
- private steerHoming(b: Bullet, dt: number) {
- let best: InternalPlayer | undefined;
- let bestDist = HOMING_RANGE;
- for (const p of this.players.values()) {
- if (!p.alive || !this.isEnemy({ login: b.owner, team: b.team }, p)) continue;
- const d = Math.hypot(p.x - b.x, p.y - b.y);
- if (d < bestDist) {
- best = p;
- bestDist = d;
- }
- }
- if (!best) return;
- const speed = Math.hypot(b.vx, b.vy);
- const current = Math.atan2(b.vy, b.vx);
- const step = HOMING_TURN_SPEED * dt;
- const angle = current + clamp(angleDiff(Math.atan2(best.y - b.y, best.x - b.x), current), -step, step);
- b.vx = Math.cos(angle) * speed;
- b.vy = Math.sin(angle) * speed;
- }
-
- /** Posune střelu po malých krocích (aby nepřeletěla zeď ani tank); vrací, zda přežila. */
- private moveBullet(b: Bullet, dt: number, now: number): boolean {
- const total = Math.hypot(b.vx, b.vy) * dt;
- const steps = Math.max(1, Math.ceil(total / 8));
- for (let i = 0; i < steps; i++) {
- const nx = b.x + b.vx * dt / steps;
- const ny = b.y + b.vy * dt / steps;
- if (this.solidAt(nx, ny)) {
- if (b.rocket) {
- this.explode(b.x, b.y, b.owner, b.team, now);
- return false;
- }
- // Bedna střelu zastaví a rozpadne se
- for (const [x, y] of [[nx, ny], [nx, b.y], [b.x, ny]]) {
- if (this.breakCrate(x, y)) return false;
- }
- if (!this.bounce || ++b.bounces > BULLET_MAX_BOUNCES) return false;
- const hitX = this.solidAt(nx, b.y);
- const hitY = this.solidAt(b.x, ny);
- if (hitX || !hitY) b.vx = -b.vx;
- if (hitY || !hitX) b.vy = -b.vy;
- continue;
- }
- b.x = nx;
- b.y = ny;
- // Zásah miny ji odpálí
- const mine = this.mines.find(m => Math.hypot(m.x - b.x, m.y - b.y) < 8 + BULLET_RADIUS);
- if (mine) {
- this.detonateMine(mine, now);
- if (b.rocket) this.explode(b.x, b.y, b.owner, b.team, now);
- return false;
- }
- for (const p of this.players.values()) {
- if (!p.alive || p.protectedUntil > now) continue;
- // Spoluhráčem střela proletí, střelce samotného (po odrazu) zasáhne
- if (p.login !== b.owner && !this.isEnemy({ login: b.owner, team: b.team }, p)) continue;
- if (Math.hypot(p.x - b.x, p.y - b.y) >= TANK_RADIUS + BULLET_RADIUS) continue;
- if (b.rocket) {
- this.explode(b.x, b.y, b.owner, b.team, now);
- } else {
- this.damage(p, b.owner, now);
- }
- return false;
- }
- }
- return true;
- }
-
- /** Výbuch rakety / miny: rozbije bedny a zasáhne tanky v okolí (kromě spoluhráčů), odpálí miny. */
- private explode(x: number, y: number, owner: string, team: Team | undefined, now: number) {
- this.explosions.push({ x, y, r: BLAST_RADIUS, at: now });
- for (let row = Math.floor((y - BLAST_RADIUS) / TILE); row <= Math.floor((y + BLAST_RADIUS) / TILE); row++) {
- for (let col = Math.floor((x - BLAST_RADIUS) / TILE); col <= Math.floor((x + BLAST_RADIUS) / TILE); col++) {
- if (this.tileAt(col, row) !== '+') continue;
- const cx = clamp(x, col * TILE, (col + 1) * TILE);
- const cy = clamp(y, row * TILE, (row + 1) * TILE);
- if (Math.hypot(x - cx, y - cy) < BLAST_RADIUS * 0.8) this.tiles[row][col] = '.';
- }
- }
- for (const p of this.players.values()) {
- if (!p.alive || p.protectedUntil > now) continue;
- if (p.login !== owner && !this.isEnemy({ login: owner, team }, p)) continue;
- if (Math.hypot(p.x - x, p.y - y) < BLAST_RADIUS + TANK_RADIUS / 2) this.damage(p, owner, now);
- }
- for (const m of this.mines.filter(m => Math.hypot(m.x - x, m.y - y) < BLAST_RADIUS)) {
- this.detonateMine(m, now);
- }
- }
-
- private detonateMine(mine: Mine, now: number) {
- if (!this.mines.includes(mine)) return;
- this.mines = this.mines.filter(m => m !== mine);
- this.explode(mine.x, mine.y, mine.owner, mine.team, now);
- }
-
- /** Aktivní mina vybuchne pod soupeřem (vlastní tým ji přejede bez újmy). */
- private triggerMines(now: number) {
- for (const m of [...this.mines]) {
- if (now < m.armedAt) continue;
- const victim = [...this.players.values()].find(p => p.alive && p.protectedUntil <= now
- && this.isEnemy({ login: m.owner, team: m.team }, p)
- && Math.hypot(p.x - m.x, p.y - m.y) < MINE_TRIGGER_RADIUS);
- if (victim) this.detonateMine(m, now);
- }
- }
-
- /** Zásah: štít ho pohltí, jinak je tank zničen. */
- private damage(victim: InternalPlayer, killerLogin: string, now: number) {
- if (!victim.alive) return;
- if (victim.shieldUntil > now) {
- victim.shieldUntil = 0;
- return;
- }
- this.kill(victim, killerLogin, now);
- }
-
- private kill(victim: InternalPlayer, killerLogin: string, now: number) {
- victim.alive = false;
- victim.deaths++;
- Object.assign(victim, NO_EFFECTS);
- victim.input = { ...victim.input, fire: false };
- this.wrecks.push({ x: victim.x, y: victim.y, angle: victim.angle, turret: victim.turret, color: victim.color, team: victim.team, at: now });
- this.wrecks = this.wrecks.slice(-12);
- this.explosions.push({ x: victim.x, y: victim.y, r: 30, at: now });
- const killer = this.players.get(killerLogin);
- if (killer === victim) {
- victim.score--;
- } else if (killer) {
- killer.kills++;
- killer.score++;
- }
- if (victim.lives !== undefined) {
- victim.lives--;
- }
- if (victim.lives === 0) {
- victim.respawnAt = undefined;
- victim.eliminatedAt = now;
- } else {
- victim.respawnAt = now + RESPAWN_MS;
- }
- }
-
- /** Znovuzrození na spawnu (v týmech na své půlce) co nejdál od soupeřů. */
- private respawn(p: InternalPlayer, now: number) {
- const alive = [...this.players.values()].filter(o => o.alive);
- const enemies = alive.filter(o => this.isEnemy(p, o));
- const isFree = ([x, y]: [number, number]) => alive.every(o => Math.hypot(o.x - x, o.y - y) >= TANK_RADIUS * 2);
- let free = this.spawnPoints(p.team).filter(isFree);
- if (!free.length) free = this.spawnPoints().filter(isFree);
- if (!free.length) return; // zkusí se v dalším ticku
- const distance = ([x, y]: [number, number]) => Math.min(Infinity, ...enemies.map(o => Math.hypot(o.x - x, o.y - y)));
- const [x, y] = free.reduce((best, s) => distance(s) > distance(best) ? s : best);
- const angle = x < FIELD_WIDTH / 2 ? 0 : Math.PI;
- Object.assign(p, {
- x, y, angle, turret: angle, alive: true, respawnAt: undefined,
- protectedUntil: now + SPAWN_PROTECTION_MS, nextFireAt: now,
- botThinkAt: undefined, botLastPos: undefined, botReverseUntil: undefined,
- });
- }
-
- private collectPickups(p: InternalPlayer, now: number) {
- this.pickups = this.pickups.filter(pk => {
- if (Math.hypot(pk.x - p.x, pk.y - p.y) >= TANK_RADIUS + PICKUP_RADIUS) return true;
- switch (pk.type) {
- case 'shield': p.shieldUntil = now + SHIELD_MS; break;
- case 'triple': p.tripleUntil = now + TRIPLE_SHOT_MS; break;
- case 'turbo': p.turboUntil = now + TURBO_MS; break;
- case 'ghost': p.ghostUntil = now + GHOST_MS; break;
- case 'homing': p.homingUntil = now + HOMING_MS; break;
- case 'rocket': p.rockets = Math.min(MAX_AMMO, p.rockets + ROCKET_AMMO); break;
- case 'mine': p.mines = Math.min(MAX_AMMO, p.mines + MINE_AMMO); break;
- case 'heart': p.lives = Math.min(LAST_MAN_LIVES, (p.lives ?? 0) + 1); break;
- }
- return false;
- });
- }
-
- /** Občas se na volném místě (dál od tanků) objeví bonus; život jen ve hře na přežití. */
- private spawnPickup(now: number) {
- if (now < this.nextPickupAt) return;
- this.nextPickupAt = now + PICKUP_INTERVAL_MS;
- if (this.pickups.length >= MAX_PICKUPS) return;
- const types: PickupType[] = ['shield', 'triple', 'rocket', 'turbo', 'ghost', 'homing', 'mine'];
- if (this.mode === 'lastman') types.push('heart');
- for (let attempt = 0; attempt < 50; attempt++) {
- const col = Math.floor(this.clock.random() * COLS);
- const row = Math.floor(this.clock.random() * ROWS);
- const x = (col + 0.5) * TILE;
- const y = (row + 0.5) * TILE;
- if (this.tileAt(col, row) !== '.') continue;
- if ([...this.players.values()].some(p => p.alive && Math.hypot(p.x - x, p.y - y) < TILE * 3)) continue;
- if (this.pickups.some(pk => pk.x === x && pk.y === y)) continue;
- this.pickups.push({ x, y, type: types[Math.floor(this.clock.random() * types.length)] });
- return;
- }
- }
-
- /** Body týmů: na čas součet bodů, na přežití součet zbylých životů. */
- private teamScores(): [number, number] {
- const scores: [number, number] = [0, 0];
- for (const p of this.players.values()) {
- if (p.team === undefined) continue;
- scores[p.team] += this.mode === 'deathmatch' ? p.score : (p.lives ?? 0);
- }
- return scores;
- }
-
- private isOver(now: number) {
- const elapsed = now - this.startedAt;
- if (this.mode === 'deathmatch') return elapsed >= DEATHMATCH_MS;
- if (elapsed >= LAST_MAN_MAX_MS) return true;
- const remaining = [...this.players.values()].filter(p => (p.lives ?? 0) > 0);
- if (remaining.length === 0) return true;
- if (this.teams) {
- const teamsLeft = new Set(remaining.map(p => p.team)).size;
- const teamsPlaying = new Set([...this.players.values()].map(p => p.team)).size;
- return teamsPlaying > 1 && teamsLeft <= 1;
- }
- return this.players.size > 1 && remaining.length <= 1;
- }
-
- private finish() {
- const individual = (a: InternalPlayer, b: InternalPlayer) => this.mode === 'deathmatch'
- ? b.score - a.score || a.deaths - b.deaths
- : (b.lives ?? 0) - (a.lives ?? 0)
- || (b.eliminatedAt ?? Infinity) - (a.eliminatedAt ?? Infinity)
- || b.kills - a.kills;
- if (this.teams) {
- const [a, b] = this.teamScores();
- this.winnerTeam = a === b ? -1 : a > b ? 0 : 1;
- }
- const winner = this.winnerTeam;
- const ranked = [...this.players.values()].sort((a, b) =>
- (winner !== undefined && winner >= 0 ? Number(b.team === winner) - Number(a.team === winner) : 0) || individual(a, b));
- ranked.forEach((p, i) => { p.place = i + 1; });
- this.bullets = [];
- this.phase = 'finished';
- this.clearTimer();
- this.emitAll();
- }
-
- // --- Boti ---
-
- /**
- * Bot v intervalu reakční doby vybere nejbližšího viditelného soupeře. Když na něj vidí, míří věží
- * a střílí; jinak jede po nejkratší cestě v mřížce (bedny po cestě rozstřílí). Občas položí minu.
- * Když se zasekne, chvíli couvá.
- */
- private driveBot(p: InternalPlayer, now: number, dt: number) {
- const profile = BOT_PROFILES[p.bot!];
- if (p.botThinkAt === undefined || now >= p.botThinkAt) {
- p.botThinkAt = now + profile.reactMs;
- this.botThink(p, now, profile);
- }
- if (p.botReverseUntil !== undefined && now < p.botReverseUntil) {
- p.input = { drive: -1, turn: p.input.turn, fire: false };
- return;
- }
- const target = p.botAngle ?? p.angle;
- const diff = angleDiff(target, p.angle);
- // Plynulé dotočení přesně na cíl, ať bot nekmitá kolem úhlu
- const turn = clamp(diff / (TANK_TURN_SPEED * Math.max(dt, 0.001)), -1, 1);
- const aligned = Math.abs(angleDiff(p.botTurret ?? p.angle, p.turret)) < profile.fireTolerance;
- p.input = {
- drive: p.input.drive,
- turn,
- fire: !!p.botFire && aligned,
- };
- }
-
- private botThink(p: InternalPlayer, now: number, profile: BotProfile) {
- // Detekce zaseknutí: chtěl jet, ale skoro se nepohnul
- if (p.botLastPos && p.input.drive !== 0 && Math.hypot(p.x - p.botLastPos[0], p.y - p.botLastPos[1]) < 2) {
- p.botReverseUntil = now + 400;
- p.input = { drive: -1, turn: this.clock.random() < 0.5 ? -1 : 1, fire: false };
- p.botLastPos = undefined;
- return;
- }
- p.botLastPos = [p.x, p.y];
-
- const dist = (o: InternalPlayer) => Math.hypot(o.x - p.x, o.y - p.y);
- const visible = (o: InternalPlayer) =>
- (o.ghostUntil <= now || dist(o) < REVEAL_DISTANCE) && this.litFor(p, o.x, o.y);
- const enemies = [...this.players.values()].filter(o => o.alive && this.isEnemy(p, o) && visible(o));
- const target = enemies.sort((a, b) => dist(a) - dist(b))[0];
- const error = (this.clock.random() * 2 - 1) * profile.aimError;
-
- if (target && p.mines > 0 && dist(target) < 220 && this.clock.random() < 0.15) {
- this.dropMine(p.login);
- }
-
- if (target && this.lineOfSight(p.x, p.y, target.x, target.y)) {
- const d = dist(target);
- // S raketou nestřílet zblízka, výbuch by zasáhl i bota
- const tooClose = p.rockets > 0 && d < BLAST_RADIUS + 30;
- p.botAngle = Math.atan2(target.y - p.y, target.x - p.x);
- p.botTurret = p.botAngle + error;
- p.botFire = target.protectedUntil <= now && !tooClose;
- p.input = { ...p.input, drive: tooClose || d < 110 ? -1 : d > 260 ? 1 : 0 };
- return;
- }
-
- // Bez výhledu: k bonusu, pokud je blízko, jinak za soupeřem; ve tmě o soupeři neví, tak prohledává mapu
- const pickup = this.pickups.find(pk => Math.hypot(pk.x - p.x, pk.y - p.y) < TILE * 6 && this.litFor(p, pk.x, pk.y));
- const goal = pickup ?? target ?? (this.hardcore ? this.roamGoal(p, now) : undefined);
- if (!goal) {
- p.botFire = false;
- p.input = { ...p.input, drive: 0 };
- return;
- }
- const next = this.nextStep(p.x, p.y, goal.x, goal.y);
- if (!next) {
- p.botFire = false;
- p.input = { ...p.input, drive: 0 };
- return;
- }
- const [col, row] = next;
- const wx = (col + 0.5) * TILE;
- const wy = (row + 0.5) * TILE;
- p.botAngle = Math.atan2(wy - p.y, wx - p.x);
- p.botTurret = p.botAngle;
- if (this.tileAt(col, row) === '+') {
- // Bedna v cestě — rozstřílet (raketou jen z bezpečné vzdálenosti)
- const tooClose = p.rockets > 0 && Math.hypot(wx - p.x, wy - p.y) < BLAST_RADIUS + 30;
- p.botFire = !tooClose;
- p.input = { ...p.input, drive: tooClose ? -1 : 0 };
- } else {
- p.botFire = false;
- p.input = { ...p.input, drive: Math.abs(angleDiff(p.botAngle, p.angle)) < 0.6 ? 1 : 0 };
- }
- }
-
- /** Zda je bod vidět pro hráče — bez hardcore vždy, jinak ve světle jeho tanku nebo spoluhráčů. */
- private litFor(viewer: InternalPlayer, x: number, y: number) {
- if (!this.hardcore) return true;
- for (const src of this.players.values()) {
- if (!src.alive || (src !== viewer && this.isEnemy(viewer, src))) continue;
- const d = Math.hypot(x - src.x, y - src.y);
- const inCone = d <= SPOTLIGHT_RANGE && Math.abs(angleDiff(Math.atan2(y - src.y, x - src.x), src.turret)) <= SPOTLIGHT_HALF_ANGLE;
- if ((d <= LIGHT_RADIUS || inCone) && this.lineOfSight(src.x, src.y, x, y)) return true;
- }
- return false;
- }
-
- /** Náhodné průjezdné místo, kam bot ve tmě jede hledat soupeře; mění se po dojetí nebo po chvíli. */
- private roamGoal(p: InternalPlayer, now: number) {
- const reached = p.botRoam && Math.hypot(p.botRoam[0] - p.x, p.botRoam[1] - p.y) < TILE;
- if (!p.botRoam || reached || now >= (p.botRoamUntil ?? 0)) {
- p.botRoam = undefined;
- for (let attempt = 0; attempt < 50 && !p.botRoam; attempt++) {
- const col = Math.floor(this.clock.random() * COLS);
- const row = Math.floor(this.clock.random() * ROWS);
- const t = this.tileAt(col, row);
- if (t !== '#' && t !== '~' && t !== '+') p.botRoam = [(col + 0.5) * TILE, (row + 0.5) * TILE];
- }
- p.botRoamUntil = now + 8000;
- }
- return p.botRoam ? { x: p.botRoam[0], y: p.botRoam[1] } : undefined;
- }
-
- private lineOfSight(x1: number, y1: number, x2: number, y2: number) {
- const dist = Math.hypot(x2 - x1, y2 - y1);
- const steps = Math.ceil(dist / 8);
- for (let i = 1; i < steps; i++) {
- const t = i / steps;
- if (this.solidAt(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t)) return false;
- }
- return true;
- }
-
- /** Další dlaždice na nejkratší cestě (BFS po mřížce; bedny jsou průjezdné — dají se rozstřílet, voda ne). */
- private nextStep(fx: number, fy: number, tx: number, ty: number): [number, number] | undefined {
- const start: [number, number] = [Math.floor(fx / TILE), Math.floor(fy / TILE)];
- const goal: [number, number] = [Math.floor(tx / TILE), Math.floor(ty / TILE)];
- if (start[0] === goal[0] && start[1] === goal[1]) return goal;
- const prev = new Map();
- const id = (c: number, r: number) => r * COLS + c;
- const queue: [number, number][] = [start];
- prev.set(id(...start), -1);
- while (queue.length) {
- const [c, r] = queue.shift()!;
- if (c === goal[0] && r === goal[1]) break;
- for (const [dc, dr] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
- const nc = c + dc;
- const nr = r + dr;
- const t = this.tileAt(nc, nr);
- if (t === '#' || t === '~' || prev.has(id(nc, nr))) continue;
- prev.set(id(nc, nr), id(c, r));
- queue.push([nc, nr]);
- }
- }
- let cur = id(...goal);
- if (!prev.has(cur)) return undefined;
- while (prev.get(cur) !== id(...start)) {
- cur = prev.get(cur)!;
- if (cur === -1) return undefined;
- }
- return [cur % COLS, Math.floor(cur / COLS)];
- }
-
- // --- Režie ---
-
- private cancel() {
- this.reset();
- this.emitAll();
- }
-
- private reset() {
- this.clearTimer();
- this.phase = 'idle';
- this.host = undefined;
- this.players.clear();
- this.tiles = [];
- this.bullets = [];
- this.pickups = [];
- this.mines = [];
- this.wrecks = [];
- this.explosions = [];
- this.winnerTeam = undefined;
- this.mapId = 'random';
- this.mode = 'lastman';
- this.teams = false;
- this.hardcore = false;
- this.bounce = false;
- }
-
- private schedule(fn: () => void, ms: number) {
- this.clearTimer();
- this.timer = this.clock.setTimeout(() => {
- this.timer = undefined;
- fn();
- }, ms);
- }
-
- private clearTimer() {
- if (this.timer !== undefined) {
- this.clock.clearTimeout(this.timer);
- this.timer = undefined;
- }
- }
-
- private emitAll() {
- this.broadcastAll(this.getState());
- }
-
- private emitWatchers() {
- this.broadcastWatchers(this.getState());
- }
-
- /** Pro testy: přesune tank a natočí ho. */
- placeForTest(login: string, x: number, y: number, angle: number) {
- const p = this.players.get(login);
- if (!p) return;
- Object.assign(p, { x, y, angle, turret: angle, aim: undefined, protectedUntil: 0 });
- }
-
- /** Pro testy: položí bonus na dané místo. */
- addPickupForTest(x: number, y: number, type: PickupType) {
- this.pickups.push({ x, y, type });
- }
-}
-
-/** Zaregistruje websocket handlery tanků. */
-export function registerTankGame(io: Server) {
- const game = new TankGame(
- state => io.emit('tanks:state', state),
- state => io.to(WATCH_ROOM).emit('tanks:state', state),
- minPlayersForEnv(2),
- );
-
- io.on('connection', socket => {
- const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'tanks:error', action);
- socket.on('tanks:sync', () => socket.emit('tanks:state', game.getState()));
- socket.on('tanks:watch', () => {
- socket.join(WATCH_ROOM);
- socket.emit('tanks:state', game.getState());
- });
- socket.on('tanks:unwatch', () => socket.leave(WATCH_ROOM));
- socket.on('tanks:join', handle(login => game.join(login)));
- socket.on('tanks:leave', handle(login => game.leave(login)));
- socket.on('tanks:start', handle(login => game.start(login)));
- socket.on('tanks:configure', handle((login, payload) => game.configure(login, (payload ?? {}) as TankOptions)));
- socket.on('tanks:team', handle((login, payload) => game.switchTeam(login, (payload as { target?: unknown })?.target)));
- socket.on('tanks:addBot', handle((login, payload) => game.addBot(login, (payload as { level?: unknown })?.level)));
- socket.on('tanks:removeBots', handle(login => game.removeBots(login)));
- socket.on('tanks:input', handle((login, payload) => game.input(login, payload)));
- socket.on('tanks:aim', handle((login, payload) => game.aimAt(login, payload)));
- socket.on('tanks:mine', handle(login => game.dropMine(login)));
- });
-}
diff --git a/server/src/tests/pongGame.test.ts b/server/src/tests/pongGame.test.ts
deleted file mode 100644
index eda24e0..0000000
--- a/server/src/tests/pongGame.test.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { PongGame, PongState, TICK_MS, COUNTDOWN_SECONDS, FIELD_HEIGHT, PADDLE_MARGIN, PADDLE_WIDTH, PADDLE_HEIGHT, WIN_SCORE, SERVE_PAUSE_MS, botLogin, BotLevel } from '../pongGame';
-import { GameError } from '../minigames';
-import { fakeClock } from './helpers/fakeClock';
-
-/** Deterministická pseudonáhoda, ať se boti nechovají pokaždé stejně jako s konstantou. */
-function lcg(seed = 1) {
- let x = seed;
- return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648;
-}
-
-function setup(random?: () => number) {
- const clock = fakeClock(random);
- const states: PongState[] = [];
- const game = new PongGame(s => states.push(s), s => states.push(s), clock);
- const last = () => states[states.length - 1];
- const player = (login: string) => last().players.find(p => p.login === login)!;
- const startGame = () => {
- game.join('alice');
- game.join('bob');
- game.start('alice');
- clock.advance(COUNTDOWN_SECONDS * 1000);
- };
- const ticks = (n: number) => clock.advance(n * TICK_MS);
- return { game, clock, last, player, startGame, ticks };
-}
-
-describe('pong', () => {
- test('je jen pro dva hráče a hru spouští zakladatel', () => {
- const ctx = setup();
- ctx.game.join('alice');
- expect(() => ctx.game.start('alice')).toThrow(GameError);
- ctx.game.join('bob');
- expect(() => ctx.game.join('carol')).toThrow(GameError);
- expect(() => ctx.game.start('bob')).toThrow(GameError);
- ctx.game.start('alice');
- expect(ctx.last().phase).toBe('countdown');
- });
-
- test('pálka se hýbe podle vstupu a nevyjede z hřiště', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.input('alice', 'up');
- ctx.ticks(10);
- expect(ctx.player('alice').y).toBeLessThan(FIELD_HEIGHT / 2);
- ctx.ticks(100);
- expect(ctx.player('alice').y).toBe(45);
- ctx.game.input('alice', 'stop');
- const y = ctx.player('alice').y;
- ctx.ticks(5);
- expect(ctx.player('alice').y).toBe(y);
- });
-
- test('míček se od pálky odrazí', () => {
- const ctx = setup();
- ctx.startGame();
- const face = PADDLE_MARGIN + PADDLE_WIDTH;
- ctx.game.setBallForTest(face + 20, FIELD_HEIGHT / 2, -400, 0);
- ctx.ticks(3);
- expect(ctx.last().ball.x).toBeGreaterThan(face);
- expect(ctx.player('bob').score).toBe(0);
- });
-
- test('minutý míček je bod pro soupeře a hra končí na vítězné skóre', () => {
- const ctx = setup();
- ctx.startGame();
- for (let i = 0; i < WIN_SCORE; i++) {
- // Míček u horního okraje, kam pálka uprostřed nedosáhne
- ctx.game.setBallForTest(60, 10, -600, 0);
- ctx.ticks(10);
- if (i < WIN_SCORE - 1) {
- expect(ctx.player('bob').score).toBe(i + 1);
- ctx.clock.advance(SERVE_PAUSE_MS);
- }
- }
- expect(ctx.last()).toMatchObject({ phase: 'finished', winner: 'bob' });
- });
-
- test('myš: pálka rychle dojede k cílové výšce a šipka myš přebije', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.aim('alice', 0);
- ctx.ticks(5);
- expect(ctx.player('alice').y).toBe(PADDLE_HEIGHT / 2);
- ctx.game.input('alice', 'down');
- ctx.ticks(5);
- expect(ctx.player('alice').y).toBeGreaterThan(PADDLE_HEIGHT / 2);
- });
-
- test('bota přidá jen zakladatel a lze ho odebrat', () => {
- const ctx = setup();
- ctx.game.join('alice');
- expect(() => ctx.game.addBot('bob', 'easy')).toThrow(GameError);
- expect(() => ctx.game.addBot('alice', 'superhard')).toThrow(GameError);
- ctx.game.addBot('alice', 'hard');
- expect(ctx.last().players.map(p => p.login)).toEqual(['alice', botLogin('hard')]);
- ctx.game.removeBot('alice');
- expect(ctx.last().players).toHaveLength(1);
- });
-
- /** Odehraje zápas člověka, který myší dokonale sleduje míček, proti botovi. */
- function playAgainstBot(level: BotLevel, maxSeconds: number) {
- const ctx = setup(lcg(7));
- ctx.game.join('alice');
- ctx.game.addBot('alice', level);
- ctx.game.start('alice');
- ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
- for (let t = 0; t < maxSeconds * 1000 && ctx.last().phase === 'playing'; t += TICK_MS) {
- ctx.game.aim('alice', ctx.last().ball.y);
- ctx.ticks(1);
- }
- return ctx;
- }
-
- test('lehký bot proti dokonalému hráči prohraje', () => {
- const ctx = playAgainstBot('easy', 300);
- expect(ctx.last()).toMatchObject({ phase: 'finished', winner: 'alice' });
- });
-
- test('nemožný bot neztratí ani bod', () => {
- const ctx = playAgainstBot('impossible', 120);
- expect(ctx.player('alice').score).toBe(0);
- expect(ctx.player(botLogin('impossible')).score).toBe(0);
- });
-});
diff --git a/server/src/tests/reactionDuel.test.ts b/server/src/tests/reactionDuel.test.ts
deleted file mode 100644
index 904bf31..0000000
--- a/server/src/tests/reactionDuel.test.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { ReactionDuel, DuelState, PENALTY_MS, CLICK_WINDOW_MS, ROUND_PAUSE_MS, MIN_DELAY_MS, LOBBY_TIMEOUT_MS } from '../reactionDuel';
-import { GameError } from '../minigames';
-import { fakeClock } from './helpers/fakeClock';
-
-function setup(minPlayers = 2) {
- const clock = fakeClock();
- const states: DuelState[] = [];
- const duel = new ReactionDuel(s => states.push(s), minPlayers, clock);
- return { duel, clock, last: () => states[states.length - 1] };
-}
-
-/** Dohraje jedno kolo: počká na vyhlášení a nechá hráče kliknout s danými časy. */
-function playRound(ctx: ReturnType, reactions: Record) {
- ctx.clock.advance(MIN_DELAY_MS);
- expect(ctx.last().phase).toBe('go');
- const sorted = Object.entries(reactions).sort((a, b) => a[1] - b[1]);
- let elapsed = 0;
- for (const [login, ms] of sorted) {
- ctx.clock.advance(ms - elapsed);
- elapsed = ms;
- ctx.duel.click(login);
- }
-}
-
-describe('reakční hra', () => {
- test('první hráč zakládá lobby a je hostitel', () => {
- const ctx = setup();
- ctx.duel.join('alice');
- ctx.duel.join('bob');
- expect(ctx.last()).toMatchObject({ phase: 'lobby', host: 'alice' });
- expect(ctx.last().players.map(p => p.login)).toEqual(['alice', 'bob']);
- });
-
- test('hru může spustit jen hostitel a s dostatkem hráčů', () => {
- const ctx = setup();
- ctx.duel.join('alice');
- expect(() => ctx.duel.start('alice')).toThrow(GameError);
- ctx.duel.join('bob');
- expect(() => ctx.duel.start('bob')).toThrow(GameError);
- ctx.duel.start('alice');
- expect(ctx.last()).toMatchObject({ phase: 'waiting', round: 1 });
- });
-
- test('do běžící hry se nelze připojit', () => {
- const ctx = setup();
- ctx.duel.join('alice');
- ctx.duel.join('bob');
- ctx.duel.start('alice');
- expect(() => ctx.duel.join('carol')).toThrow(GameError);
- });
-
- test('vyhrává nejnižší součet časů, nejpomalejší je poslední', () => {
- const ctx = setup();
- ctx.duel.join('alice');
- ctx.duel.join('bob');
- ctx.duel.start('alice');
- playRound(ctx, { alice: 200, bob: 300 });
- expect(ctx.last()).toMatchObject({ phase: 'roundResult', round: 1 });
- ctx.clock.advance(ROUND_PAUSE_MS);
- playRound(ctx, { alice: 250, bob: 220 });
- ctx.clock.advance(ROUND_PAUSE_MS);
- playRound(ctx, { alice: 210, bob: 260 });
-
- const state = ctx.last();
- expect(state.phase).toBe('finished');
- expect(state.players.find(p => p.login === 'alice')?.total).toBe(660);
- expect(state.players.find(p => p.login === 'bob')?.total).toBe(780);
- expect(state.winner).toBe('alice');
- expect(state.loser).toBe('bob');
- });
-
- test('předčasný start a nekliknutí znamenají trestný čas', () => {
- const ctx = setup();
- ctx.duel.join('alice');
- ctx.duel.join('bob');
- ctx.duel.start('alice');
- ctx.duel.click('alice'); // před vyhlášením
- ctx.clock.advance(MIN_DELAY_MS);
- ctx.clock.advance(CLICK_WINDOW_MS); // bob neklikne
- const state = ctx.last();
- expect(state.phase).toBe('roundResult');
- expect(state.players.find(p => p.login === 'alice')?.rounds[0]).toEqual({ ms: null, falseStart: true });
- expect(state.players.find(p => p.login === 'bob')?.rounds[0]).toEqual({ ms: null });
- expect(state.players.every(p => p.total === PENALTY_MS)).toBe(true);
- });
-
- test('odchodem hostitele se lobby ruší', () => {
- const ctx = setup();
- ctx.duel.join('alice');
- ctx.duel.join('bob');
- ctx.duel.leave('alice');
- expect(ctx.last()).toMatchObject({ phase: 'idle', players: [] });
- });
-
- test('neodstartované lobby po čase vyprší', () => {
- const ctx = setup();
- ctx.duel.join('alice');
- ctx.clock.advance(LOBBY_TIMEOUT_MS);
- expect(ctx.last().phase).toBe('idle');
- });
-});
diff --git a/server/src/tests/snakeGame.test.ts b/server/src/tests/snakeGame.test.ts
deleted file mode 100644
index 0acf9b5..0000000
--- a/server/src/tests/snakeGame.test.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { SnakeGame, SnakeState, TICK_MS, COUNTDOWN_SECONDS, WIDTH, HEIGHT } from '../snakeGame';
-import { GameError } from '../minigames';
-import { fakeClock } from './helpers/fakeClock';
-
-/** Náhoda vracející zadané hodnoty a pak nuly (jídlo pak padá do rohu [0,0]). */
-const sequence = (...values: number[]) => () => values.length ? values.shift()! : 0;
-
-/** Hodnoty náhody, které umístí jídlo na danou buňku. */
-const foodAt = (x: number, y: number) => [x / WIDTH, y / HEIGHT];
-
-function setup(random?: () => number) {
- const clock = fakeClock(random);
- const states: SnakeState[] = [];
- const game = new SnakeGame(s => states.push(s), s => states.push(s), 1, clock);
- const last = () => states[states.length - 1];
- const player = (login: string) => last().players.find(p => p.login === login)!;
- const startGame = (...logins: string[]) => {
- logins.forEach(l => game.join(l));
- game.start(logins[0]);
- clock.advance(COUNTDOWN_SECONDS * 1000);
- };
- const ticks = (n: number) => clock.advance(n * TICK_MS);
- return { game, clock, last, player, startGame, ticks };
-}
-
-describe('had', () => {
- test('po odpočtu začne hra a had se posouvá o políčko za tick', () => {
- const ctx = setup();
- ctx.startGame('alice');
- expect(ctx.last().phase).toBe('playing');
- expect(ctx.player('alice').body).toEqual([[5, 10], [4, 10], [3, 10]]);
- ctx.ticks(2);
- expect(ctx.player('alice').body).toEqual([[7, 10], [6, 10], [5, 10]]);
- });
-
- test('zatáčí podle směru a obrat o 180° ignoruje', () => {
- const ctx = setup();
- ctx.startGame('alice');
- ctx.game.turn('alice', 'left'); // obrat — ignoruje se
- ctx.ticks(1);
- expect(ctx.player('alice').body[0]).toEqual([6, 10]);
- ctx.game.turn('alice', 'up');
- ctx.ticks(1);
- expect(ctx.player('alice').body[0]).toEqual([6, 9]);
- });
-
- test('nesmyslný směr se ignoruje', () => {
- const ctx = setup();
- ctx.startGame('alice');
- ctx.game.turn('alice', 'diagonal');
- ctx.ticks(1);
- expect(ctx.player('alice').body[0]).toEqual([6, 10]);
- });
-
- test('sněděné jídlo prodlouží hada a přidá bod', () => {
- const ctx = setup(sequence(...foodAt(7, 10)));
- ctx.startGame('alice');
- expect(ctx.last().food).toEqual([[7, 10]]);
- ctx.ticks(2);
- expect(ctx.player('alice').score).toBe(1);
- expect(ctx.player('alice').body).toHaveLength(4);
- expect(ctx.last().food).not.toContainEqual([7, 10]);
- });
-
- test('náraz do zdi ukončí sólo hru', () => {
- const ctx = setup();
- ctx.startGame('alice');
- ctx.ticks(WIDTH);
- expect(ctx.last().phase).toBe('finished');
- expect(ctx.player('alice')).toMatchObject({ alive: false, place: 1 });
- });
-
- test('ve hře více hráčů vyhrává poslední přeživší', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob');
- // alice startuje v řádku 6, nahoru do zdi dorazí v 7. ticku
- ctx.game.turn('alice', 'up');
- ctx.ticks(7);
- expect(ctx.last().phase).toBe('finished');
- expect(ctx.player('bob')).toMatchObject({ alive: true, place: 1 });
- expect(ctx.player('alice')).toMatchObject({ alive: false, place: 2 });
- });
-
- test('čelní srážka zabije oba hady', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob');
- // alice (5,6)→ doprava, bob (26,13)← doleva; po 7 tickách alice zatočí dolů
- // a po dalších 7 se obě hlavy potkají na (12,13)
- ctx.ticks(7);
- ctx.game.turn('alice', 'down');
- ctx.ticks(7);
- const state = ctx.last();
- expect(state.phase).toBe('finished');
- expect(state.players.every(p => !p.alive)).toBe(true);
- });
-
- test('do běžící hry se nelze připojit', () => {
- const ctx = setup();
- ctx.startGame('alice');
- expect(() => ctx.game.join('bob')).toThrow(GameError);
- });
-});
diff --git a/server/src/tests/tankGame.test.ts b/server/src/tests/tankGame.test.ts
deleted file mode 100644
index 019e836..0000000
--- a/server/src/tests/tankGame.test.ts
+++ /dev/null
@@ -1,451 +0,0 @@
-import {
- TankGame, TankState, MAPS, COLS, ROWS, TICK_MS, COUNTDOWN_SECONDS, RESPAWN_MS, DEATHMATCH_MS,
- LAST_MAN_LIVES, MAX_PLAYERS, TANK_RADIUS, ROCKET_AMMO, MINE_AMMO, MINE_ARM_MS, FIELD_WIDTH,
-} from '../tankGame';
-import { GameError } from '../minigames';
-import { fakeClock } from './helpers/fakeClock';
-
-/** Deterministická pseudonáhoda pro boty. */
-function lcg(seed = 1) {
- let x = seed;
- return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648;
-}
-
-function setup(random?: () => number) {
- const clock = fakeClock(random);
- const states: TankState[] = [];
- const game = new TankGame(s => states.push(s), s => states.push(s), 2, clock);
- const last = () => states[states.length - 1];
- const player = (login: string) => last().players.find(p => p.login === login)!;
- /** Odrážení je ve výchozím stavu vypnuté, testy ho zapínají — sebezničení odraženou střelou je nejjednodušší zásah. */
- const startGame = (mode: 'deathmatch' | 'lastman' = 'deathmatch', all = ['alice', 'bob'], map = 'arena', teams = false, bounce = true) => {
- all.forEach(l => game.join(l));
- game.configure(all[0], { mode, mapId: map, teams, bounce });
- game.start(all[0]);
- clock.advance(COUNTDOWN_SECONDS * 1000);
- };
- const ticks = (n: number) => clock.advance(n * TICK_MS);
- /** Jeden výstřel: stisk na jeden tick a puštění. */
- const shoot = (login: string) => {
- game.input(login, { fire: true });
- ticks(1);
- game.input(login, { fire: false });
- };
- return { game, clock, last, player, startGame, ticks, shoot };
-}
-
-describe('tanky', () => {
- test('mapy mají správný rozměr, dost spawnů a všechna volná místa jsou propojená', () => {
- for (const map of MAPS) {
- expect(map.rows).toHaveLength(ROWS);
- map.rows.forEach(r => expect(r).toHaveLength(COLS));
- map.rows.forEach(r => expect(r).toMatch(/^[.#+~:%S]+$/));
- // Každý tým má na své půlce dost spawnů
- const leftSpawns = map.rows.join('').split('').filter((c, i) => c === 'S' && i % COLS < COLS / 2).length;
- expect(leftSpawns).toBeGreaterThanOrEqual(MAX_PLAYERS / 2);
- // Bedny se dají rozstřílet, takže se počítají jako průjezdné; zeď a voda ne
- const blocked = (c: string) => c === '#' || c === '~';
- const open: [number, number][] = [];
- map.rows.forEach((r, y) => [...r].forEach((c, x) => { if (!blocked(c)) open.push([x, y]); }));
- const seen = new Set([open[0].join()]);
- const queue = [open[0]];
- while (queue.length) {
- const [x, y] = queue.shift()!;
- for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
- const nx = x + dx, ny = y + dy;
- if (map.rows[ny]?.[nx] === undefined || blocked(map.rows[ny][nx]) || seen.has(`${nx},${ny}`)) continue;
- seen.add(`${nx},${ny}`);
- queue.push([nx, ny]);
- }
- }
- expect(seen.size).toBe(open.length);
- }
- });
-
- test('hru spouští zakladatel a potřebuje alespoň dva tanky (boti se počítají)', () => {
- const ctx = setup();
- ctx.game.join('alice');
- expect(() => ctx.game.start('alice')).toThrow(GameError);
- expect(() => ctx.game.addBot('bob', 'easy')).toThrow(GameError);
- ctx.game.addBot('alice', 'easy');
- expect(ctx.last().players.map(p => p.login)).toContain('🤖 Bot 1 (Lehký)');
- ctx.game.start('alice');
- expect(ctx.last().phase).toBe('countdown');
- expect(() => ctx.game.join('bob')).toThrow(GameError);
- });
-
- test('tank jede kupředu a zastaví se o okraj hřiště', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 100, 60, Math.PI);
- ctx.game.input('alice', { drive: 1 });
- ctx.ticks(30);
- expect(ctx.player('alice').x).toBe(TANK_RADIUS);
- expect(ctx.player('alice').y).toBe(60);
- });
-
- test('nesmyslné ovládání se ořízne', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 300, 60, 0);
- ctx.game.input('alice', { drive: 5, turn: 'x' });
- ctx.ticks(10);
- expect(ctx.player('alice').x).toBeGreaterThan(300);
- expect(ctx.player('alice').angle).toBe(0);
- });
-
- test('směrové ovládání jede rovnou zvoleným směrem', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 300, 300, 0);
- ctx.game.input('alice', { dx: 0, dy: -1 });
- ctx.ticks(10);
- const p = ctx.player('alice');
- expect(p.angle).toBeCloseTo(-Math.PI / 2, 2);
- expect(p.x).toBeCloseTo(300, 0);
- expect(p.y).toBeLessThan(300);
- });
-
- test('směrové ovládání bez stisknutého směru tank zastaví a nechá natočený', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 300, 300, 0);
- ctx.game.input('alice', { dx: -1, dy: 0 });
- ctx.ticks(1);
- ctx.game.input('alice', { dx: 0, dy: 0 });
- const { x, angle } = ctx.player('alice');
- expect(x).toBeLessThan(300);
- ctx.ticks(5);
- expect(ctx.player('alice').angle).toBe(angle);
- expect(ctx.player('alice').x).toBe(x);
- });
-
- test('zásah zničí soupeře, přidá bod a soupeř se po chvíli znovu objeví', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 100, 60, 0);
- ctx.game.placeForTest('bob', 300, 60, 0);
- ctx.shoot('alice');
- ctx.ticks(30);
- expect(ctx.player('alice')).toMatchObject({ score: 1, kills: 1 });
- expect(ctx.player('bob')).toMatchObject({ alive: false, deaths: 1 });
- ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1);
- expect(ctx.player('bob')).toMatchObject({ alive: true, protected: true });
- });
-
- test('věž se natočí za myší nezávisle na korbě a střílí tím směrem', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 60, 60, 0);
- ctx.game.placeForTest('bob', 60, 250, 0);
- ctx.game.aimAt('alice', { x: 60, y: 250 });
- ctx.ticks(10);
- expect(ctx.player('alice').turret).toBeCloseTo(Math.PI / 2, 1);
- expect(ctx.player('alice').angle).toBe(0);
- ctx.shoot('alice');
- ctx.ticks(30);
- expect(ctx.player('bob')).toMatchObject({ alive: false });
- expect(ctx.player('alice').kills).toBe(1);
- // Bez myši se věž vrátí do směru jízdy
- ctx.game.aimAt('alice', { x: null });
- ctx.ticks(10);
- expect(ctx.player('alice').turret).toBeCloseTo(0, 1);
- });
-
- test('ve výchozím stavu je režim poslední přeživší a střela na zdi zanikne', () => {
- const ctx = setup();
- ctx.game.join('alice');
- ctx.game.join('bob');
- expect(ctx.last()).toMatchObject({ mode: 'lastman', bounce: false });
- ctx.game.configure('alice', { mapId: 'arena' });
- ctx.game.start('alice');
- ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
- ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2);
- ctx.game.placeForTest('bob', 900, 500, 0);
- ctx.shoot('alice');
- ctx.ticks(20);
- expect(ctx.player('alice')).toMatchObject({ alive: true, lives: LAST_MAN_LIVES });
- expect(ctx.last().bullets).toHaveLength(0);
- });
-
- test('odražená vlastní střela zničí i střelce a stojí bod', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2);
- ctx.shoot('alice');
- ctx.ticks(20);
- expect(ctx.player('alice')).toMatchObject({ alive: false, score: -1, kills: 0 });
- });
-
- test('střela rozbije bednu a zanikne', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 380, 20, 0);
- ctx.shoot('alice');
- ctx.ticks(10);
- expect(ctx.last().tiles[0][11]).toBe('.');
- expect(ctx.last().tiles[0][12]).toBe('+');
- expect(ctx.last().bullets).toHaveLength(0);
- });
-
- test('výstřel s hlavní v bedně ji rozbije rovnou', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 425, 20, 0);
- ctx.shoot('alice');
- expect(ctx.last().tiles[0][11]).toBe('.');
- expect(ctx.last().bullets).toHaveLength(0);
- });
-
- test('na přežití vypadne, kdo přijde o všechny životy, a hra skončí', () => {
- const ctx = setup();
- ctx.startGame('lastman');
- expect(ctx.player('bob').lives).toBe(LAST_MAN_LIVES);
- for (let i = 0; i < LAST_MAN_LIVES; i++) {
- ctx.game.placeForTest('alice', 900, 300, 0);
- ctx.game.placeForTest('bob', 100, 60, -Math.PI / 2);
- ctx.shoot('bob');
- ctx.ticks(20);
- if (i < LAST_MAN_LIVES - 1) ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1);
- }
- expect(ctx.last().phase).toBe('finished');
- expect(ctx.player('alice').place).toBe(1);
- expect(ctx.player('bob')).toMatchObject({ place: 2, lives: 0 });
- });
-
- test('hra na čas skončí po vypršení limitu', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.clock.advance(DEATHMATCH_MS + 100);
- expect(ctx.last().phase).toBe('finished');
- expect(ctx.last().players.every(p => p.place !== undefined)).toBe(true);
- });
-
- test('boti hrají mezi sebou a navzájem se ničí', () => {
- const ctx = setup(lcg(7));
- ctx.game.join('alice');
- ctx.game.configure('alice', { mode: 'deathmatch', mapId: 'maze' });
- ctx.game.addBot('alice', 'hard');
- ctx.game.addBot('alice', 'medium');
- ctx.game.start('alice');
- ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
- ctx.clock.advance(60 * 1000);
- const bots = ctx.last().players.filter(p => p.bot);
- expect(bots.reduce((sum, b) => sum + b.kills, 0)).toBeGreaterThan(0);
- });
-});
-
-describe('tanky — bonusy', () => {
- /** Položí bonus pod tank a nechá ho sebrat. */
- const pickUp = (ctx: ReturnType, login: string, type: Parameters[2]) => {
- ctx.ticks(1); // aktuální poloha po placeForTest
- const p = ctx.player(login);
- ctx.game.addPickupForTest(p.x, p.y, type);
- ctx.ticks(1);
- };
-
- test('raketa vybuchne při zásahu, zničí tank a zbyde po něm hořící vrak', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 100, 60, 0);
- ctx.game.placeForTest('bob', 300, 60, 0);
- pickUp(ctx, 'alice', 'rocket');
- expect(ctx.player('alice').rockets).toBe(ROCKET_AMMO);
- ctx.shoot('alice');
- expect(ctx.last().bullets[0]).toMatchObject({ rocket: true });
- ctx.ticks(30);
- expect(ctx.player('alice')).toMatchObject({ rockets: ROCKET_AMMO - 1, kills: 1, shots: 1 });
- expect(ctx.player('bob').alive).toBe(false);
- expect(ctx.last().wrecks).toHaveLength(1);
- });
-
- test('výbuch rakety rozbije i sousední bedny', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 380, 20, 0);
- ctx.game.placeForTest('bob', 900, 500, 0);
- pickUp(ctx, 'alice', 'rocket');
- ctx.shoot('alice');
- ctx.ticks(10);
- expect(ctx.last().tiles[0].slice(11, 13)).toBe('..');
- expect(ctx.last().explosions.length).toBeGreaterThan(0);
- });
-
- test('mina se aktivuje až po chvíli a vybuchne pod soupeřem', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 100, 60, 0);
- ctx.game.placeForTest('bob', 500, 300, 0);
- pickUp(ctx, 'alice', 'mine');
- ctx.game.dropMine('alice');
- ctx.ticks(1);
- expect(ctx.player('alice').mines).toBe(MINE_AMMO - 1);
- expect(ctx.last().mines[0]).toMatchObject({ x: 80, y: 60, armed: false });
- ctx.game.placeForTest('alice', 500, 500, 0);
- ctx.ticks(Math.ceil(MINE_ARM_MS / TICK_MS));
- ctx.game.placeForTest('bob', 200, 60, Math.PI);
- ctx.game.input('bob', { drive: 1 });
- ctx.ticks(40);
- expect(ctx.player('bob').alive).toBe(false);
- expect(ctx.player('alice').kills).toBe(1);
- expect(ctx.last().mines).toHaveLength(0);
- });
-
- test('štít pohltí jeden zásah', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 100, 60, 0);
- ctx.game.placeForTest('bob', 300, 60, 0);
- pickUp(ctx, 'bob', 'shield');
- ctx.shoot('alice');
- ctx.ticks(30);
- expect(ctx.player('bob')).toMatchObject({ alive: true, shield: false });
- });
-
- test('turbo zrychlí jízdu', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 100, 60, 0);
- ctx.game.placeForTest('bob', 100, 300, 0);
- pickUp(ctx, 'alice', 'turbo');
- ctx.game.input('alice', { drive: 1 });
- ctx.game.input('bob', { drive: 1 });
- ctx.ticks(20);
- expect(ctx.player('alice').turbo).toBe(true);
- expect(ctx.player('alice').x - 100).toBeGreaterThan((ctx.player('bob').x - 100) * 1.4);
- });
-
- test('naváděná střela se stočí k soupeři', () => {
- const ctx = setup();
- ctx.startGame();
- ctx.game.placeForTest('alice', 100, 60, 0);
- ctx.game.placeForTest('bob', 300, 130, 0);
- pickUp(ctx, 'alice', 'homing');
- ctx.shoot('alice');
- ctx.ticks(40);
- expect(ctx.player('bob').alive).toBe(false);
- });
-
- test('život navíc se ve hře na přežití vrátí, ale nepřesáhne maximum', () => {
- const ctx = setup();
- ctx.startGame('lastman');
- ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2);
- ctx.shoot('alice');
- ctx.ticks(20);
- expect(ctx.player('alice').lives).toBe(LAST_MAN_LIVES - 1);
- ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1);
- pickUp(ctx, 'alice', 'heart');
- pickUp(ctx, 'alice', 'heart');
- expect(ctx.player('alice').lives).toBe(LAST_MAN_LIVES);
- });
-});
-
-describe('tanky — terén', () => {
- test('voda tank zastaví, ale střela ji přeletí', () => {
- const ctx = setup();
- // Les: řádek 7 (y 280–320) je řeka, v prvním sloupci bez brodu
- ctx.startGame('deathmatch', ['alice', 'bob'], 'forest');
- ctx.game.placeForTest('alice', 20, 240, Math.PI / 2);
- ctx.game.placeForTest('bob', 20, 380, 0);
- ctx.game.input('alice', { drive: 1 });
- ctx.ticks(30);
- expect(ctx.player('alice').y).toBeLessThanOrEqual(280 - TANK_RADIUS);
- ctx.game.input('alice', { drive: 0 });
- ctx.shoot('alice');
- ctx.ticks(20);
- expect(ctx.player('bob').alive).toBe(false);
- });
-
- test('porost zpomaluje jízdu', () => {
- const ctx = setup();
- // Les: v prvním řádku jsou sloupce 3–4 (x 120–200) porost, třetí řádek vlevo je volný
- ctx.startGame('deathmatch', ['alice', 'bob'], 'forest');
- ctx.game.placeForTest('alice', 130, 20, 0);
- ctx.game.placeForTest('bob', 20, 100, 0);
- ctx.game.input('alice', { drive: 1 });
- ctx.game.input('bob', { drive: 1 });
- ctx.ticks(10);
- expect(ctx.player('alice').x - 130).toBeLessThan((ctx.player('bob').x - 20) * 0.7);
- });
-});
-
-describe('tanky — hardcore (tma)', () => {
- /** alice a těžký bot ve volném druhém řádku arény, bot natočený od alice. */
- function duel(hardcore: boolean, distance: number) {
- const ctx = setup();
- ctx.game.join('alice');
- ctx.game.configure('alice', { mode: 'deathmatch', mapId: 'arena', hardcore });
- ctx.game.addBot('alice', 'hard');
- ctx.game.start('alice');
- ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
- const bot = ctx.last().players.find(p => p.bot)!.login;
- ctx.game.placeForTest('alice', 100, 60, 0);
- ctx.game.placeForTest(bot, 100 + distance, 60, 0);
- ctx.ticks(30);
- return { ...ctx, bot };
- }
-
- test('zakladatel ho zapne v lobby', () => {
- const ctx = setup();
- ctx.game.join('alice');
- ctx.game.configure('alice', { hardcore: true });
- expect(ctx.last().hardcore).toBe(true);
- });
-
- test('bot ve tmě nevidí soupeře mimo světlo, bez tmy ano', () => {
- expect(duel(true, 600).last().bullets).toHaveLength(0);
- const lit = duel(false, 600);
- expect(lit.last().bullets.length > 0 || !lit.player('alice').alive).toBe(true);
- });
-
- test('bot ve tmě vidí soupeře ve svém okolí', () => {
- const ctx = duel(true, 110);
- expect(ctx.last().bullets.length > 0 || !ctx.player('alice').alive).toBe(true);
- });
-});
-
-describe('tanky — týmy', () => {
- const LOGINS = ['alice', 'bob', 'carol', 'dave'];
-
- test('hráči se střídavě rozdělí do týmů a jde se přesunout', () => {
- const ctx = setup();
- LOGINS.forEach(l => ctx.game.join(l));
- ctx.game.configure('alice', { teams: true });
- expect(ctx.last().players.map(p => p.team)).toEqual([0, 1, 0, 1]);
- expect(() => ctx.game.switchTeam('bob', 'carol')).toThrow(GameError);
- ctx.game.switchTeam('bob');
- expect(ctx.player('bob').team).toBe(0);
- ctx.game.switchTeam('alice', 'bob');
- expect(ctx.player('bob').team).toBe(1);
- });
-
- test('týmy začínají každý na své půlce a střela spoluhráčem proletí', () => {
- const ctx = setup();
- ctx.startGame('deathmatch', LOGINS, 'arena', true);
- for (const p of ctx.last().players) {
- expect(p.x < FIELD_WIDTH / 2).toBe(p.team === 0);
- }
- // alice a carol jsou tým 0, bob tým 1
- ctx.game.placeForTest('alice', 100, 60, 0);
- ctx.game.placeForTest('carol', 200, 60, 0);
- ctx.game.placeForTest('bob', 300, 60, 0);
- ctx.shoot('alice');
- ctx.ticks(30);
- expect(ctx.player('carol').alive).toBe(true);
- expect(ctx.player('bob').alive).toBe(false);
- expect(ctx.last().teamScores).toEqual([1, 0]);
- });
-
- test('na čas vyhrává tým s víc body', () => {
- const ctx = setup();
- ctx.startGame('deathmatch', LOGINS, 'arena', true);
- ctx.game.placeForTest('bob', 100, 60, 0);
- ctx.game.placeForTest('alice', 300, 60, 0);
- ctx.shoot('bob');
- ctx.clock.advance(DEATHMATCH_MS);
- const state = ctx.last();
- expect(state.phase).toBe('finished');
- expect(state.winnerTeam).toBe(1);
- expect(state.players.filter(p => p.place! <= 2).every(p => p.team === 1)).toBe(true);
- });
-});
diff --git a/server/src/tests/tetrisGame.test.ts b/server/src/tests/tetrisGame.test.ts
deleted file mode 100644
index 934e032..0000000
--- a/server/src/tests/tetrisGame.test.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import { TetrisGame, TetrisState, TetrisSettings, GarbageEvent, COUNTDOWN_SECONDS, JOIN_GRACE_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS, DEFAULT_SETTINGS } from '../tetrisGame';
-import { GameError } from '../minigames';
-import { fakeClock } from './helpers/fakeClock';
-
-const BOARD = '0'.repeat(BOARD_WIDTH * (BOARD_HEIGHT - 1)) + '1'.repeat(BOARD_WIDTH);
-
-function setup(minPlayers = 1) {
- const clock = fakeClock();
- const all: TetrisState[] = [];
- const watched: TetrisState[] = [];
- const garbage: GarbageEvent[] = [];
- const game = new TetrisGame(s => all.push(s), s => watched.push(s), e => garbage.push(e), minPlayers, clock);
- const last = () => all[all.length - 1];
- const startWith = (settings: Partial, ...logins: string[]) => {
- logins.forEach(l => game.join(l));
- game.configure(logins[0], settings);
- game.start(logins[0]);
- clock.advance(COUNTDOWN_SECONDS * 1000);
- };
- const startGame = (...logins: string[]) => startWith({}, ...logins);
- return { game, clock, all, watched, garbage, last, startGame, startWith };
-}
-
-describe('tetris', () => {
- test('společnou hru nejde spustit jen s jedním hráčem', () => {
- const ctx = setup(2);
- ctx.game.join('alice');
- expect(() => ctx.game.start('alice')).toThrow(GameError);
- expect(ctx.last()).toMatchObject({ phase: 'lobby' });
- ctx.game.join('bob');
- ctx.game.start('alice');
- expect(ctx.last()).toMatchObject({ phase: 'countdown' });
- });
-
- test('po odpočtu začne hra se společným seedem', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob');
- expect(ctx.last()).toMatchObject({ phase: 'playing' });
- expect(typeof ctx.last().seed).toBe('number');
- });
-
- test('desky se sledujícím posílají sloučeně', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob');
- ctx.game.update('alice', { board: BOARD, score: 100, lines: 1 });
- ctx.game.update('bob', { board: BOARD, score: 40, lines: 0 });
- expect(ctx.watched).toHaveLength(0);
- ctx.clock.advance(WATCH_FLUSH_MS);
- expect(ctx.watched).toHaveLength(1);
- expect(ctx.watched[0].players.find(p => p.login === 'alice')).toMatchObject({ board: BOARD, score: 100, lines: 1 });
- });
-
- test('neplatná deska se zahodí', () => {
- const ctx = setup();
- ctx.startGame('alice');
- ctx.game.update('alice', { board: 'x'.repeat(200), score: 5 });
- ctx.game.update('alice', { board: '0'.repeat(10) });
- ctx.clock.advance(WATCH_FLUSH_MS);
- expect(ctx.watched).toHaveLength(0);
- });
-
- test('smazání více řádků pošle odpad soupeři, jeden řádek nic', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob');
- ctx.game.cleared('alice', 1);
- expect(ctx.garbage).toHaveLength(0);
- ctx.game.cleared('alice', 4);
- expect(ctx.garbage).toEqual([{ target: 'bob', from: 'alice', lines: 4 }]);
- });
-
- test('útoky jsou omezené frekvencí a velikostí', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob');
- ctx.game.cleared('alice', 2);
- ctx.game.cleared('alice', 2); // příliš brzy
- ctx.game.cleared('alice', 99); // nesmysl
- expect(ctx.garbage).toHaveLength(1);
- ctx.clock.advance(MIN_ATTACK_GAP_MS);
- ctx.game.cleared('alice', 3);
- expect(ctx.garbage.map(g => g.lines)).toEqual([1, 2]);
- });
-
- test('útok jde soupeři, který dostal nejméně odpadu', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob', 'carol', 'dave');
- ctx.game.cleared('alice', 4); // všichni 0 → první z nich (random = 0)
- ctx.clock.advance(MIN_ATTACK_GAP_MS);
- ctx.game.cleared('alice', 2);
- ctx.clock.advance(MIN_ATTACK_GAP_MS);
- ctx.game.cleared('alice', 3);
- ctx.game.cleared('bob', 2); // carol 1, dave 2, alice 0 → alice
- ctx.game.cleared('carol', 2); // alice 1, bob 4, dave 2 → alice
- expect(ctx.garbage.map(g => g.target)).toEqual(['bob', 'carol', 'dave', 'alice', 'alice']);
- });
-
- test('odražený odpad zmenší útok, víc než tabulka poslat nejde', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob');
- ctx.game.cleared('alice', 4, 1);
- ctx.clock.advance(MIN_ATTACK_GAP_MS);
- ctx.game.cleared('alice', 2, 0); // vše odraženo → žádný útok
- ctx.game.cleared('alice', 2, 50);
- ctx.clock.advance(MIN_ATTACK_GAP_MS);
- ctx.game.cleared('alice', 3, -5);
- expect(ctx.garbage.map(g => g.lines)).toEqual([1, 1]);
- });
-
- test('poslední hráč ve hře vyhrává', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob', 'carol');
- ctx.game.died('bob');
- expect(ctx.last().phase).toBe('playing');
- ctx.game.died('alice');
- const state = ctx.last();
- expect(state.phase).toBe('finished');
- const place = (l: string) => state.players.find(p => p.login === l)?.place;
- expect([place('carol'), place('alice'), place('bob')]).toEqual([1, 2, 3]);
- });
-
- test('mrtvý hráč neútočí a nedostává odpad', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob', 'carol');
- ctx.game.died('bob');
- ctx.game.cleared('bob', 4);
- expect(ctx.garbage).toHaveLength(0);
- ctx.game.cleared('alice', 4);
- expect(ctx.garbage[0].target).toBe('carol');
- });
-
- test('hráč, který přestal posílat desku, je vyřazen', () => {
- const ctx = setup();
- ctx.startGame('alice', 'bob');
- // alice hraje dál, bob zavřel okno
- for (let t = 0; t <= PLAYER_TIMEOUT_MS + 5000; t += 2000) {
- ctx.game.update('alice', { board: BOARD, score: 0, lines: 0 });
- ctx.clock.advance(2000);
- }
- expect(ctx.last().phase).toBe('finished');
- expect(ctx.last().players.find(p => p.login === 'alice')?.place).toBe(1);
- });
-
- test('nastavení mění jen zakladatel v lobby, neplatné hodnoty se ignorují', () => {
- const ctx = setup();
- ctx.game.join('alice');
- ctx.game.join('bob');
- expect(ctx.last().settings).toEqual(DEFAULT_SETTINGS);
- expect(() => ctx.game.configure('bob', { targeting: 'random' })).toThrow(GameError);
- ctx.game.configure('alice', { targeting: 'payback', winMode: 'nesmysl', timeLimit: 7 });
- expect(ctx.last().settings).toEqual({ ...DEFAULT_SETTINGS, targeting: 'payback' });
- ctx.game.start('alice');
- expect(() => ctx.game.configure('alice', { winMode: 'score' })).toThrow(GameError);
- });
-
- test('random míří náhodně bez ohledu na obdržený odpad', () => {
- const ctx = setup();
- ctx.startWith({ targeting: 'random' }, 'alice', 'bob', 'carol');
- ctx.game.cleared('alice', 4);
- ctx.clock.advance(MIN_ATTACK_GAP_MS);
- ctx.game.cleared('alice', 4); // random = 0 → pořád první soupeř
- expect(ctx.garbage.map(g => g.target)).toEqual(['bob', 'bob']);
- });
-
- test('payback vrací útok poslednímu útočníkovi, jinak míří jako even', () => {
- const ctx = setup();
- ctx.startWith({ targeting: 'payback' }, 'alice', 'bob', 'carol');
- ctx.game.cleared('carol', 2); // carol nikdo nenapadl → even → alice
- ctx.game.cleared('alice', 2); // alice napadla carol → carol
- ctx.game.cleared('bob', 2); // bob nikdo nenapadl → even (alice 1, carol 1) → alice
- ctx.clock.advance(MIN_ATTACK_GAP_MS);
- ctx.game.cleared('alice', 2); // naposledy ji napadl bob → bob
- ctx.game.died('bob');
- ctx.game.cleared('bob', 2); // mrtvý neútočí
- ctx.game.cleared('carol', 2); // naposledy ji napadla alice → alice
- expect(ctx.garbage.map(g => `${g.from}>${g.target}`)).toEqual(['carol>alice', 'alice>carol', 'bob>alice', 'alice>bob', 'carol>alice']);
- });
-
- test('hra na skóre končí časovým limitem a pořadí je podle skóre', () => {
- const ctx = setup();
- ctx.startWith({ winMode: 'score', timeLimit: 120 }, 'alice', 'bob', 'carol');
- expect(ctx.last().remainingMs).toBe(120_000);
- ctx.game.update('bob', { board: BOARD, score: 900, lines: 8 });
- ctx.game.died('bob');
- ctx.game.died('alice'); // zbývá jediný hráč, ale hraje se dál
- expect(ctx.last().phase).toBe('playing');
- ctx.game.update('alice', { board: BOARD, score: 100, lines: 1 }); // mrtvému se skóre už nemění
- ctx.game.update('carol', { board: BOARD, score: 500, lines: 4 });
- ctx.clock.advance(120_000);
- const state = ctx.last();
- expect(state.phase).toBe('finished');
- const place = (l: string) => state.players.find(p => p.login === l)?.place;
- expect([place('bob'), place('carol'), place('alice')]).toEqual([1, 2, 3]);
- });
-
- test('hra na skóre skončí dřív, když vypadnou všichni', () => {
- const ctx = setup();
- ctx.startWith({ winMode: 'score' }, 'alice', 'bob');
- ctx.game.died('alice');
- expect(ctx.last().phase).toBe('playing');
- ctx.game.died('bob');
- expect(ctx.last().phase).toBe('finished');
- });
-
- test('do běžící hry se nelze připojit', () => {
- const ctx = setup();
- ctx.startGame('alice');
- expect(() => ctx.game.join('bob')).toThrow(GameError);
- });
-
- test('během odpočtu se lze ještě připojit', () => {
- const ctx = setup();
- ctx.game.join('alice');
- ctx.game.start('alice');
- ctx.clock.advance(1000);
- ctx.game.join('bob');
- expect(ctx.last()).toMatchObject({ phase: 'countdown', countdown: COUNTDOWN_SECONDS - 1 });
- expect(ctx.last().players.map(p => p.login)).toEqual(['alice', 'bob']);
- ctx.clock.advance((COUNTDOWN_SECONDS - 1) * 1000);
- expect(ctx.last().phase).toBe('playing');
- });
-
- test('připojení na poslední chvíli prodlouží odpočet', () => {
- const ctx = setup();
- ctx.game.join('alice');
- ctx.game.start('alice');
- ctx.clock.advance((COUNTDOWN_SECONDS - 1) * 1000);
- ctx.game.join('bob');
- expect(ctx.last()).toMatchObject({ phase: 'countdown', countdown: JOIN_GRACE_SECONDS });
- ctx.clock.advance((JOIN_GRACE_SECONDS - 1) * 1000);
- expect(ctx.last().phase).toBe('countdown');
- ctx.clock.advance(1000);
- expect(ctx.last().phase).toBe('playing');
- });
-});
diff --git a/server/src/tetrisGame.ts b/server/src/tetrisGame.ts
deleted file mode 100644
index 2d8f1fb..0000000
--- a/server/src/tetrisGame.ts
+++ /dev/null
@@ -1,392 +0,0 @@
-import { Server } from "socket.io";
-import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } from "./minigames";
-
-/**
- * Multiplayerový Tetris (battle) přes websocket.
- *
- * Každý hráč hraje vlastní desku v prohlížeči (kvůli odezvě ovládání), server koordinuje:
- * lobby a odpočet (během něj se ještě dá připojit), společný seed pro pořadí kostek, přeposílání desek soupeřům,
- * útoky (smazané řádky → odpadní řádky soupeři podle zvoleného režimu míření) a vyřazování.
- * Zakladatel lobby volí režim míření a způsob hodnocení: buď vyhrává poslední hráč ve hře
- * (sólo hra končí prohrou), nebo nejvyšší skóre po uplynutí časového limitu.
- *
- * Klientovi se věří (skóre, smazané řádky) — jde o kancelářskou hru; útoky jsou ale
- * omezené co do velikosti i frekvence. Stav je v paměti, předpokládá jednu repliku.
- */
-
-export const BOARD_WIDTH = 10;
-export const BOARD_HEIGHT = 20;
-export const MAX_PLAYERS = 8;
-/** Odpočet po spuštění hry — během něj se ještě mohou připojit opozdilci. */
-export const COUNTDOWN_SECONDS = 5;
-/** Kolik sekund odpočtu má nejméně zbývat po připojení dalšího hráče, ať se stihne připravit. */
-export const JOIN_GRACE_SECONDS = 3;
-/** Neodstartované lobby se po této době zruší (ms). */
-export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
-/** Pojistka proti nekonečné hře (ms). */
-export const MAX_GAME_MS = 15 * 60 * 1000;
-/** Min. rozestup útoků jednoho hráče (ms) — víc řádků naráz ani rychleji fyzicky smazat nejde. */
-export const MIN_ATTACK_GAP_MS = 150;
-export const WATCH_ROOM = 'tetris-watchers';
-/** Hráč, od kterého tak dlouho nepřišla deska (zavřený tab, výpadek), se bere jako vyřazený (ms). */
-export const PLAYER_TIMEOUT_MS = 15_000;
-/** Jak často se kontroluje, zda hráči žijí (ms). */
-const WATCHDOG_MS = 5000;
-/** Průběžné desky se sledujícím posílají nejvýš jednou za tuto dobu (ms). */
-export const WATCH_FLUSH_MS = 100;
-
-/** Kolik odpadních řádků pošle smazání daného počtu řádků naráz. */
-export const GARBAGE_FOR_LINES: Record = { 1: 0, 2: 1, 3: 2, 4: 4 };
-
-export type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
-
-/**
- * Komu jde útok:
- * - random — náhodnému živému soupeři,
- * - even — soupeři, který zatím dostal nejméně odpadu,
- * - payback — tomu, kdo útočníka naposledy napadl (jinak jako even).
- */
-export type TetrisTargeting = 'random' | 'even' | 'payback';
-/** survival — vyhrává poslední ve hře; score — nejvyšší skóre po časovém limitu. */
-export type TetrisWinMode = 'survival' | 'score';
-
-export type TetrisSettings = {
- targeting: TetrisTargeting,
- winMode: TetrisWinMode,
- /** Délka hry v sekundách (jen pro winMode = score). */
- timeLimit: number,
-};
-
-export const TARGETING_MODES: TetrisTargeting[] = ['random', 'even', 'payback'];
-export const WIN_MODES: TetrisWinMode[] = ['survival', 'score'];
-/** Povolené délky hry na skóre (s). */
-export const TIME_LIMITS = [120, 180, 300];
-export const DEFAULT_SETTINGS: TetrisSettings = { targeting: 'even', winMode: 'survival', timeLimit: 180 };
-
-export type TetrisPlayer = {
- login: string,
- /** Deska po řádcích shora, znak 0 = prázdno, 1–8 = barva (8 = odpad). */
- board: string,
- score: number,
- lines: number,
- alive: boolean,
- place?: number,
-};
-
-export type TetrisState = {
- phase: TetrisPhase,
- host?: string,
- players: TetrisPlayer[],
- /** Seed generátoru kostek — všichni hráči dostanou stejné pořadí. */
- seed?: number,
- countdown?: number,
- minPlayers: number,
- settings: TetrisSettings,
- /** Zbývající čas hry na skóre (ms), jen ve fázi playing. */
- remainingMs?: number,
-};
-
-export type GarbageEvent = { target: string, from: string, lines: number };
-
-type InternalPlayer = TetrisPlayer & {
- lastAttack: number,
- lastSeen: number,
- deathOrder?: number,
- /** Kolik odpadních řádků už hráč od soupeřů dostal (režim even). */
- garbageReceived: number,
- /** Kdo hráče naposledy napadl (režim payback). */
- lastAttacker?: string,
-};
-
-const EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT);
-const BOARD_PATTERN = new RegExp(`^[0-8]{${BOARD_WIDTH * BOARD_HEIGHT}}$`);
-
-export class TetrisGame {
- private phase: TetrisPhase = 'idle';
- private host?: string;
- private players = new Map();
- private seed?: number;
- private countdown = 0;
- private deaths = 0;
- private settings: TetrisSettings = { ...DEFAULT_SETTINGS };
- /** Kdy skončí hra na skóre (čas z clock.now()). */
- private endsAt?: number;
- private timer: unknown;
- /** Čeká už odeslání průběžného stavu sledujícím? */
- private flushPending = false;
-
- /**
- * @param broadcastAll stav pro všechny (změny lobby a fáze)
- * @param broadcastWatchers průběžný stav jen pro klienty s otevřenou hrou
- * @param sendGarbage útok na hráče (doručuje se sledujícím, klient si vybere svůj)
- */
- constructor(
- private readonly broadcastAll: (state: TetrisState) => void,
- private readonly broadcastWatchers: (state: TetrisState) => void,
- private readonly sendGarbage: (event: GarbageEvent) => void,
- private readonly minPlayers = 2,
- private readonly clock: Clock = defaultClock,
- ) { }
-
- getState(): TetrisState {
- return {
- phase: this.phase,
- host: this.host,
- players: [...this.players.values()].map(p => ({
- login: p.login, board: p.board, score: p.score, lines: p.lines, alive: p.alive, place: p.place,
- })),
- seed: this.seed,
- countdown: this.phase === 'countdown' ? this.countdown : undefined,
- minPlayers: this.minPlayers,
- settings: { ...this.settings },
- remainingMs: this.phase === 'playing' && this.endsAt !== undefined
- ? Math.max(0, this.endsAt - this.clock.now()) : undefined,
- };
- }
-
- join(login: string) {
- if (this.phase === 'idle' || this.phase === 'finished') {
- this.reset();
- this.phase = 'lobby';
- this.host = login;
- this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
- } else if (this.phase !== 'lobby' && this.phase !== 'countdown') {
- throw new GameError('Hra už běží, počkejte na další.');
- }
- if (!this.players.has(login)) {
- if (this.players.size >= MAX_PLAYERS) {
- throw new GameError('Lobby je plné.');
- }
- this.players.set(login, { login, board: EMPTY_BOARD, score: 0, lines: 0, alive: true, lastAttack: 0, lastSeen: 0, garbageReceived: 0 });
- // Opozdilec připojený na poslední chvíli dostane čas se připravit (počet hráčů je omezený, odpočet se neprodlužuje donekonečna)
- if (this.phase === 'countdown' && this.countdown < JOIN_GRACE_SECONDS) {
- this.countdown = JOIN_GRACE_SECONDS;
- }
- }
- this.emitAll();
- }
-
- leave(login: string) {
- if (this.phase !== 'lobby' || !this.players.has(login)) {
- return;
- }
- this.players.delete(login);
- if (login === this.host || this.players.size === 0) {
- this.cancel();
- return;
- }
- this.emitAll();
- }
-
- /** Nastavení hry — jen zakladatel a jen v lobby; neplatné hodnoty se ignorují. */
- configure(login: string, payload: unknown) {
- if (this.phase !== 'lobby') {
- throw new GameError('Nastavení lze měnit jen v lobby.');
- }
- if (login !== this.host) {
- throw new GameError('Nastavení může měnit jen ten, kdo lobby založil.');
- }
- const { targeting, winMode, timeLimit } = (payload ?? {}) as Partial>;
- if (TARGETING_MODES.includes(targeting as TetrisTargeting)) this.settings.targeting = targeting as TetrisTargeting;
- if (WIN_MODES.includes(winMode as TetrisWinMode)) this.settings.winMode = winMode as TetrisWinMode;
- if (TIME_LIMITS.includes(timeLimit as number)) this.settings.timeLimit = timeLimit as number;
- this.emitAll();
- }
-
- start(login: string) {
- if (this.phase !== 'lobby') {
- throw new GameError('Není otevřené žádné lobby.');
- }
- if (login !== this.host) {
- throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
- }
- if (this.players.size < this.minPlayers) {
- throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
- }
- this.seed = Math.floor(this.clock.random() * 2 ** 31);
- this.phase = 'countdown';
- this.countdown = COUNTDOWN_SECONDS;
- this.emitAll();
- this.schedule(() => this.countdownStep(), 1000);
- }
-
- /** Průběžný stav desky hráče (posílá klient po položení kostky a průběžně). */
- update(login: string, payload: unknown) {
- const p = this.players.get(login);
- if (!p || !p.alive || this.phase !== 'playing') return;
- const { board, score, lines } = (payload ?? {}) as { board?: unknown, score?: unknown, lines?: unknown };
- if (typeof board !== 'string' || !BOARD_PATTERN.test(board)) return;
- p.lastSeen = this.clock.now();
- p.board = board;
- if (Number.isSafeInteger(score) && (score as number) >= 0) p.score = score as number;
- if (Number.isSafeInteger(lines) && (lines as number) >= 0) p.lines = lines as number;
- this.emitWatchers();
- }
-
- /**
- * Hráč smazal řádky — případný útok jde živému soupeři podle režimu míření (viz TetrisTargeting).
- *
- * @param sent kolik řádků po odražení vlastního čekajícího odpadu zbylo na útok (počítá klient);
- * bez něj se pošle plný útok podle tabulky, víc než tabulka ale nikdy
- */
- cleared(login: string, count: unknown, sent?: unknown) {
- const p = this.players.get(login);
- if (!p || !p.alive || this.phase !== 'playing' || !Number.isInteger(count)) return;
- const attack = GARBAGE_FOR_LINES[count as number] ?? 0;
- const garbage = Number.isInteger(sent) ? Math.min(Math.max(sent as number, 0), attack) : attack;
- const now = this.clock.now();
- if (garbage === 0 || now - p.lastAttack < MIN_ATTACK_GAP_MS) return;
- p.lastAttack = now;
- const opponents = [...this.players.values()].filter(t => t.alive && t.login !== login);
- if (opponents.length === 0) return;
- const target = this.pickTarget(p, opponents);
- target.garbageReceived += garbage;
- target.lastAttacker = login;
- this.sendGarbage({ target: target.login, from: login, lines: garbage });
- }
-
- private pickTarget(attacker: InternalPlayer, opponents: InternalPlayer[]): InternalPlayer {
- const pick = (list: InternalPlayer[]) => list[Math.floor(this.clock.random() * list.length)];
- if (this.settings.targeting === 'random') return pick(opponents);
- if (this.settings.targeting === 'payback') {
- const revenge = opponents.find(t => t.login === attacker.lastAttacker);
- if (revenge) return revenge;
- }
- // even (a payback, dokud útočníka nikdo nenapadl): kdo dostal nejméně, při shodě náhodně
- const least = Math.min(...opponents.map(t => t.garbageReceived));
- return pick(opponents.filter(t => t.garbageReceived === least));
- }
-
- /** Hráči přetekla deska. */
- died(login: string) {
- const p = this.players.get(login);
- if (!p || !p.alive || this.phase !== 'playing') return;
- p.alive = false;
- p.deathOrder = ++this.deaths;
- const alive = [...this.players.values()].filter(x => x.alive).length;
- // Ve hře na skóre se hraje do limitu, dokud je ve hře aspoň jeden hráč
- const lastStanding = this.settings.winMode === 'survival' && this.players.size > 1 && alive <= 1;
- if (alive === 0 || lastStanding) {
- this.finish();
- return;
- }
- this.emitAll();
- }
-
- private countdownStep() {
- this.countdown--;
- if (this.countdown > 0) {
- this.emitAll();
- this.schedule(() => this.countdownStep(), 1000);
- return;
- }
- this.phase = 'playing';
- const now = this.clock.now();
- this.players.forEach(p => { p.lastSeen = now; });
- const duration = this.settings.winMode === 'score' ? this.settings.timeLimit * 1000 : MAX_GAME_MS;
- this.endsAt = this.settings.winMode === 'score' ? now + duration : undefined;
- this.emitAll();
- this.schedule(() => this.finish(), duration);
- this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
- }
-
- /** Vyřadí hráče, kteří přestali posílat desku (zavřeli okno, ztratili spojení). */
- private watchdog() {
- if (this.phase !== 'playing') return;
- const now = this.clock.now();
- for (const p of this.players.values()) {
- if (p.alive && now - p.lastSeen > PLAYER_TIMEOUT_MS) this.died(p.login);
- }
- if (this.phase === 'playing') this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
- }
-
- /**
- * Pořadí — survival: živí první (podle skóre), pak vyřazení od posledního;
- * score: podle skóre, při shodě déle hrající.
- */
- private finish() {
- const survival = (a: InternalPlayer, b: InternalPlayer) => (b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity);
- const ranked = [...this.players.values()].sort(this.settings.winMode === 'score'
- ? (a, b) => b.score - a.score || survival(a, b)
- : (a, b) => survival(a, b) || b.score - a.score);
- ranked.forEach((p, i) => { p.place = i + 1; });
- this.phase = 'finished';
- this.clearTimer();
- this.emitAll();
- }
-
- private cancel() {
- this.reset();
- this.emitAll();
- }
-
- private reset() {
- this.clearTimer();
- this.phase = 'idle';
- this.host = undefined;
- this.players.clear();
- this.seed = undefined;
- this.deaths = 0;
- this.endsAt = undefined;
- }
-
- private schedule(fn: () => void, ms: number) {
- this.clearTimer();
- this.timer = this.clock.setTimeout(() => {
- this.timer = undefined;
- fn();
- }, ms);
- }
-
- private clearTimer() {
- if (this.timer !== undefined) {
- this.clock.clearTimeout(this.timer);
- this.timer = undefined;
- }
- }
-
- private emitAll() {
- this.broadcastAll(this.getState());
- }
-
- /** Aktualizace desek se slučují, aby 8 hráčů nezahltilo sledující. */
- private emitWatchers() {
- if (this.flushPending) return;
- this.flushPending = true;
- this.clock.setTimeout(() => {
- this.flushPending = false;
- if (this.phase === 'playing') this.broadcastWatchers(this.getState());
- }, WATCH_FLUSH_MS);
- }
-}
-
-/** Zaregistruje websocket handlery Tetrisu. */
-export function registerTetrisGame(io: Server) {
- const game = new TetrisGame(
- state => io.emit('tetris:state', state),
- state => io.to(WATCH_ROOM).emit('tetris:state', state),
- event => io.to(WATCH_ROOM).emit('tetris:garbage', event),
- // Sólo hra je jen Trénink v prohlížeči, společná hra by blokovala ostatní
- minPlayersForEnv(2),
- );
-
- io.on('connection', socket => {
- const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'tetris:error', action);
- socket.on('tetris:sync', () => socket.emit('tetris:state', game.getState()));
- socket.on('tetris:watch', () => {
- socket.join(WATCH_ROOM);
- socket.emit('tetris:state', game.getState());
- });
- socket.on('tetris:unwatch', () => socket.leave(WATCH_ROOM));
- socket.on('tetris:join', handle(login => game.join(login)));
- socket.on('tetris:leave', handle(login => game.leave(login)));
- socket.on('tetris:configure', handle((login, payload) => game.configure(login, payload)));
- socket.on('tetris:start', handle(login => game.start(login)));
- socket.on('tetris:update', handle((login, payload) => game.update(login, payload)));
- socket.on('tetris:cleared', handle((login, payload) => {
- const { count, sent } = (payload ?? {}) as { count?: unknown, sent?: unknown };
- game.cleared(login, count, sent);
- }));
- socket.on('tetris:died', handle(login => game.died(login)));
- });
-}
diff --git a/server/src/websocket.ts b/server/src/websocket.ts
index aef9277..7371f0b 100644
--- a/server/src/websocket.ts
+++ b/server/src/websocket.ts
@@ -1,13 +1,7 @@
import { DefaultEventsMap, Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
-import { registerReactionDuel } from "./reactionDuel";
-import { registerSnakeGame } from "./snakeGame";
-import { registerTetrisGame } from "./tetrisGame";
-import { registerPongGame } from "./pongGame";
import { registerDrawingGame } from "./drawingGame";
-import { registerTankGame } from "./tankGame";
-import { registerLobbyChat } from "./minigames";
let io: Server;
let pubClient: ReturnType;
@@ -35,13 +29,7 @@ export const initWebsocket = (server: any) => {
// console.log(`Client disconnected: ${socket.id}`);
});
});
- registerReactionDuel(io);
- registerSnakeGame(io);
- registerTetrisGame(io);
- registerPongGame(io);
registerDrawingGame(io);
- registerTankGame(io);
- registerLobbyChat(io);
return io;
};
diff --git a/types/paths/config/config.yml b/types/paths/config/config.yml
index 3fb2378..c8f7200 100644
--- a/types/paths/config/config.yml
+++ b/types/paths/config/config.yml
@@ -1,6 +1,6 @@
get:
operationId: getConfig
- summary: Vrátí veřejnou runtime konfiguraci pro klienta (Sentry DSN, limity objednávek, způsob přihlášení, zapnuté funkce). Nevyžaduje autentizaci.
+ summary: Vrátí veřejnou runtime konfiguraci pro klienta (Sentry DSN, limity objednávek, způsob přihlášení, zapnuté funkce, odkazy). Nevyžaduje autentizaci.
security: [] # Nevyžaduje autentizaci
responses:
"200":
@@ -9,7 +9,7 @@ get:
application/json:
schema:
type: object
- required: [sentry, limits, auth, features]
+ required: [sentry, limits, auth, features, links]
properties:
auth:
type: object
@@ -25,6 +25,14 @@ get:
storeAdmin:
type: boolean
description: Správa obchodů je zapnutá (server má nastavené ADMIN_PASSWORD)
+ links:
+ type: object
+ required: [lobbyUrl]
+ properties:
+ lobbyUrl:
+ type: string
+ nullable: true
+ description: Adresa aplikace Lobby s minihrami (LOBBY_URL), null = odkaz není nastaven
limits:
type: object
required: [group]