) => [...pressed].some(k => keys.has(k));
+ const update = () => {
+ const input = {
+ drive: (any(FORWARD_KEYS) ? 1 : 0) - (any(BACK_KEYS) ? 1 : 0),
+ turn: (any(RIGHT_KEYS) ? 1 : 0) - (any(LEFT_KEYS) ? 1 : 0),
+ fire: any(FIRE_KEYS) || mouseFire,
+ };
+ 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]);
+
+ // 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 (canvasRef.current && state && (inGame || phase === 'finished')) {
+ drawTanks(canvasRef.current, state, flashes.current, login);
+ }
+ }, [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
+ ? ' · ↑↓ / W S jízda, ←→ / A D otáčení, myš míření, klik / mezerník střelba, E / pravé tlačítko mina'
+ : ' · sledujete hru')}
+
+ {renderScores()}
+
+ );
+
+ 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.'}
+
+ >
+ );
+
+ const renderBody = () => {
+ switch (phase) {
+ case 'idle':
+ return (
+ <>
+
+ Tanková bitva pro více hráčů. Jezdí se šipkami nebo WSAD, 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} )}
+
+ >
+ );
+ 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/games/tankRender.ts b/client/src/games/tankRender.ts
new file mode 100644
index 0000000..40aab03
--- /dev/null
+++ b/client/src/games/tankRender.ts
@@ -0,0 +1,904 @@
+/**
+ * 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' },
+ rocket: { icon: '🚀', label: 'rakety — výbuch zasáhne i okolí' },
+ turbo: { icon: '💨', label: 'turbo' },
+ ghost: { icon: '👻', label: 'neviditelnost' },
+ homing: { icon: '🧲', label: 'naváděné střely' },
+ mine: { icon: '💣', label: 'miny (E / pravé tlačítko)' },
+ heart: { icon: '❤️', label: 'život navíc' },
+};
+
+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);
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+}
+
+// --- 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 světlý kruh, ať se v hemžení najde
+ if (mine) {
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([4, 4]);
+ ctx.beginPath();
+ ctx.arc(p.x, p.y, TANK_RADIUS + 5, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ // 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/themeIcons.ts b/client/src/games/themeIcons.ts
index df6e6be..1ac26f7 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';
+export type GameId = 'reaction' | 'snake' | 'tetris' | 'pong' | 'drawing' | 'tanks';
/** Ikony miniher podle vizuálního stylu aplikace. */
const ICONS: Record> = {
- simple: { reaction: '⚡', snake: '🐍', tetris: '🧱', pong: '🏓', drawing: '🎨' },
- // Dostihy: startovní vlajka, kůň místo hada, mrkve jako kostky, žokej, trofej
- umamusume: { reaction: '🏁', snake: '🐎', tetris: '🥕', pong: '🏇', drawing: '🏆' },
- // Duha, jednorožec, drahokamy, balónky a paleta
- mlp: { reaction: '🌈', snake: '🦄', tetris: '💎', pong: '🎈', drawing: '🖌️' },
- // Pekelná korespondence: plamen, rudá nit, lampion, slaměná panenka, svitek
- hellgirl: { reaction: '🔥', snake: '🪢', tetris: '🏮', pong: '🪆', drawing: '📜' },
- // Klub lehké hudby: bicí Ritsu, želva Ton-chan, dortíky k čaji, kytara Gitah a notový zápis
- kon: { reaction: '🥁', snake: '🐢', tetris: '🍰', pong: '🎸', drawing: '🎼' },
- // Kessoku Band: mikrofon Kity, kabel od kytary, krabice od manga, bicí Nijiky a sešit s texty Bocchi
- bocchi: { reaction: '🎤', snake: '🔌', tetris: '📦', pong: '🥁', drawing: '📝' },
+ 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: '🎸' },
};
/** Styl z — pro místa mimo React kontext nastavení. */
diff --git a/client/src/hooks/useMinigameSecret.ts b/client/src/hooks/useMinigameSecret.ts
index c43ee44..678d6d5 100644
--- a/client/src/hooks/useMinigameSecret.ts
+++ b/client/src/hooks/useMinigameSecret.ts
@@ -4,6 +4,7 @@ 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";
type Minigame = {
@@ -52,6 +53,13 @@ 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';
diff --git a/server/changelogs/2026-10-20.json b/server/changelogs/2026-10-20.json
index a9ed916..c88785b 100644
--- a/server/changelogs/2026-10-20.json
+++ b/server/changelogs/2026-10-20.json
@@ -2,4 +2,5 @@
"Tetris: kostky se otáčí proti směru hodinových ručiček kolem svého středu, tyčka a S/Z kostky už jen přepínají mezi vodorovnou a svislou polohou",
"Tetris: nová kostka se objeví nad hrací plochou, takže ji jde otočit dřív, než do plochy vjede",
"Minihry jdou hrát i na mobilu a tabletu 📱 — Tetris, had i Pong mají dotykové ovládání a okna her se na telefonu roztáhnou přes celou obrazovku"
+ "Přibyla další minihra — tentokrát se bude střílet 😉"
]
diff --git a/server/src/tankGame.ts b/server/src/tankGame.ts
new file mode 100644
index 0000000..e994302
--- /dev/null
+++ b/server/src/tankGame.ts
@@ -0,0 +1,1396 @@
+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 = 24;
+export const ROWS = 15;
+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 };
+
+type Input = { drive: number, turn: number, fire: boolean };
+
+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. */
+ 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 };
+ p.input = { drive: axis(data.drive), turn: axis(data.turn), fire: data.fire === true };
+ }
+
+ /** 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) {
+ p.angle = (p.angle + p.input.turn * TANK_TURN_SPEED * dt) % (Math.PI * 2);
+ if (!p.input.drive) return;
+ let speed = p.input.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 = p.input.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/tankGame.test.ts b/server/src/tests/tankGame.test.ts
new file mode 100644
index 0000000..278d604
--- /dev/null
+++ b/server/src/tests/tankGame.test.ts
@@ -0,0 +1,425 @@
+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,
+} 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('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 < 480).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/websocket.ts b/server/src/websocket.ts
index 5519c2c..4429d48 100644
--- a/server/src/websocket.ts
+++ b/server/src/websocket.ts
@@ -6,6 +6,7 @@ import { registerSnakeGame } from "./snakeGame";
import { registerTetrisGame } from "./tetrisGame";
import { registerPongGame } from "./pongGame";
import { registerDrawingGame } from "./drawingGame";
+import { registerTankGame } from "./tankGame";
let io: Server;
let pubClient: ReturnType;
@@ -38,6 +39,7 @@ export const initWebsocket = (server: any) => {
registerTetrisGame(io);
registerPongGame(io);
registerDrawingGame(io);
+ registerTankGame(io);
return io;
};