diff --git a/client/src/components/Header.tsx b/client/src/components/Header.tsx index 2fb76ce..cd46afa 100644 --- a/client/src/components/Header.tsx +++ b/client/src/components/Header.tsx @@ -7,6 +7,7 @@ import SnakeGame from "./SnakeGame"; import TetrisGame from "./TetrisGame"; import PongGame from "./PongGame"; import DrawingGame from "./DrawingGame"; +import TankGame from "./TankGame"; import { useMinigameSecret } from "../hooks/useMinigameSecret"; import { useSettings, ThemePreference } from "../context/settings"; import HuePicker from "./HuePicker"; @@ -209,6 +210,7 @@ export default function Header({ choices, dayIndex }: Props) { + diff --git a/client/src/components/TankGame.scss b/client/src/components/TankGame.scss new file mode 100644 index 0000000..6f7e3bb --- /dev/null +++ b/client/src/components/TankGame.scss @@ -0,0 +1,154 @@ +// Pozvánka tanků nad pozvánkami ostatních her, aby se nepřekrývaly +.duel-invite.tanks-invite { + bottom: 276px; +} + +.tanks-game-modal { + .tanks-board-wrap { + position: relative; + max-width: 960px; + margin: 0 auto; + } + + .tanks-board { + display: block; + width: 100%; + height: auto; + border-radius: 8px; + cursor: crosshair; + } + + .tanks-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 5rem; + font-weight: 700; + color: #fff; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); + pointer-events: none; + + &.small { + font-size: 2rem; + } + } + + .tanks-status { + text-align: center; + opacity: 0.75; + margin: 8px 0; + } + + .tanks-scores { + max-width: 960px; + margin: 0 auto; + } + + .tanks-out td { + opacity: 0.5; + } + + .tanks-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 6px; + } + + .tanks-players { + list-style: none; + padding-left: 0; + + &.two-columns { + columns: 2; + } + } + + .tanks-switch { + padding: 0 6px; + line-height: 1; + text-decoration: none; + } + + .tanks-teams { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + } + + .tanks-teamscore { + display: flex; + justify-content: center; + align-items: baseline; + gap: 24px; + font-size: 1.1rem; + margin-bottom: 8px; + + strong { + font-size: 1.8rem; + } + } + + .tanks-equipment { + text-align: center; + font-size: 1.1rem; + margin-top: 8px; + } + + .tanks-items { + white-space: nowrap; + } + + .tanks-pickups { + columns: 2; + list-style: none; + padding-left: 0; + } + + .tanks-config { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 8px; + + .form-label { + margin-bottom: 2px; + font-size: 0.85rem; + } + + .form-select { + min-width: 190px; + } + } + + // Volby hry pod sebou: popisek vlevo, ovládání vpravo + .tanks-options { + display: grid; + grid-template-columns: max-content 1fr; + align-items: center; + gap: 8px 16px; + margin-bottom: 12px; + + .form-check { + margin-bottom: 0; + } + } + + .tanks-option-label { + font-size: 0.85rem; + opacity: 0.8; + } + + .tanks-bot-picker { + display: flex; + gap: 6px; + + .form-select { + width: auto; + min-width: 130px; + } + } +} diff --git a/client/src/components/TankGame.tsx b/client/src/components/TankGame.tsx new file mode 100644 index 0000000..35a0b56 --- /dev/null +++ b/client/src/components/TankGame.tsx @@ -0,0 +1,509 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button, Form, Modal, Table } from "react-bootstrap"; +import { socket } from "../context/socket"; +import { useAuth } from "../context/auth"; +import { getToken } from "../Utils"; +import { useGameIcon } from "../games/themeIcons"; +import { + BIOMES, BotLevel, drawTanks, FLASH_MS, MuzzleFlash, PICKUPS, PickupType, tankColor, TankMode, TankPhase, + TankPlayer, TankState, TEAM_COLORS, TEAM_NAMES, +} from "../games/tankRender"; +import "./TankGame.scss"; + +const OPEN_EVENT = 'luncher:open-tanks'; + +/** Otevře okno tanků. */ +export const openTankGame = () => window.dispatchEvent(new Event(OPEN_EVENT)); + +/** Jak často se posílá poloha myši (ms). */ +const AIM_SEND_MS = 30; +const MAX_TEAM_SIZE = 3; + +const MEDALS = ['🥇', '🥈', '🥉']; +const MODES: { value: TankMode, label: string, description: string }[] = [ + { value: 'lastman', label: 'Poslední přeživší', description: 'Každý má 3 životy, vyhrává ten, komu nějaký zbyde.' }, + { value: 'deathmatch', label: 'Na čas', description: '3 minuty, po zničení se tank vrací do hry. Vyhrává nejvíc bodů.' }, +]; +const BOT_LEVELS: { value: BotLevel, label: string }[] = [ + { value: 'easy', label: 'Lehký' }, + { value: 'medium', label: 'Střední' }, + { value: 'hard', label: 'Těžký' }, +]; + +const FORWARD_KEYS = new Set(['ArrowUp', 'KeyW']); +const BACK_KEYS = new Set(['ArrowDown', 'KeyS']); +const LEFT_KEYS = new Set(['ArrowLeft', 'KeyA']); +const RIGHT_KEYS = new Set(['ArrowRight', 'KeyD']); +const FIRE_KEYS = new Set(['Space']); +const MINE_KEYS = new Set(['KeyE']); +const GAME_KEYS = new Set([...FORWARD_KEYS, ...BACK_KEYS, ...LEFT_KEYS, ...RIGHT_KEYS, ...FIRE_KEYS, ...MINE_KEYS]); + +const formatTime = (ms: number) => { + const s = Math.ceil(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +}; + +/** Aktivní efekty a munice hráče jako ikony (pro výpis výbavy a tabulku). */ +function equipment(p: TankPlayer) { + const items: string[] = []; + const effect = (on: boolean, type: PickupType) => { if (on) items.push(PICKUPS[type].icon); }; + effect(p.shield, 'shield'); + effect(p.triple, 'triple'); + effect(p.turbo, 'turbo'); + effect(p.ghost, 'ghost'); + effect(p.homing, 'homing'); + if (p.rockets) items.push(`${PICKUPS.rocket.icon}${p.rockets}`); + if (p.mines) items.push(`${PICKUPS.mine.icon}${p.mines}`); + return items; +} + +/** Tanky pro více hráčů — lobby, hrací plocha a výsledky; simulaci drží server. */ +export default function TankGame() { + const auth = useAuth(); + const login = auth?.login; + const [state, setState] = useState(null); + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + const [botLevel, setBotLevel] = useState('medium'); + const canvasRef = useRef(null); + const prevPhase = useRef('idle'); + const flashes = useRef([]); + const prevShots = useRef(new Map()); + const icon = useGameIcon('tanks'); + + const send = useCallback((event: string, extra?: object) => { + setError(null); + socket.emit(event, { token: getToken(), ...extra }); + }, []); + + useEffect(() => { + const onState = (s: TankState) => setState(s); + const onError = (msg: string) => setError(msg); + const onOpen = () => setOpen(true); + const sync = () => socket.emit('tanks:sync'); + socket.on('tanks:state', onState); + socket.on('tanks:error', onError); + socket.on('connect', sync); + window.addEventListener(OPEN_EVENT, onOpen); + sync(); + return () => { + socket.off('tanks:state', onState); + socket.off('tanks:error', onError); + socket.off('connect', sync); + window.removeEventListener(OPEN_EVENT, onOpen); + }; + }, []); + + // Průběžné snímky hry dostávají jen klienti s otevřeným oknem + useEffect(() => { + if (!open) return; + const watch = () => socket.emit('tanks:watch'); + watch(); + socket.on('connect', watch); + return () => { + socket.off('connect', watch); + socket.emit('tanks:unwatch'); + }; + }, [open]); + + const isPlayer = !!login && !!state?.players.some(p => p.login === login); + const isHost = !!login && login === state?.host; + const phase = state?.phase ?? 'idle'; + const inGame = phase === 'countdown' || phase === 'playing'; + const me = state?.players.find(p => p.login === login); + + // Hráčům se okno otevře samo, jakmile hra začne + useEffect(() => { + if (prevPhase.current === 'lobby' && phase === 'countdown' && isPlayer) { + setOpen(true); + } + prevPhase.current = phase; + }, [phase, isPlayer]); + + // Ovládání: držené klávesy a tlačítko myši → stav ovládání, posílá se jen při změně + useEffect(() => { + if (!open || !inGame || !isPlayer) return; + const pressed = new Set(); + let mouseFire = false; + let sent = ''; + const any = (keys: Set) => [...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 = () => ( + + + + + + {state?.mode === 'lastman' ? : } + + + + + + {ranked.map((p, i) => ( + + + + + + + + ))} + +
#HráčŽivotyBodyZničil(a)Zničen(a)
{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) && ( + + )} +
  • + ); + + 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 => )} + + + + Mapa + send('tanks:configure', { mapId: e.target.value })}> + + {state.maps.map(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 => )} + + + + )} + {state.players.some(p => p.bot) && ( + + )} +
    + + )} +
    + + ) : ( +

    + 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(); + } + if (phase === 'lobby') { + if (!isPlayer) { + buttons.push(); + } else { + buttons.push(); + if (isHost) { + buttons.push(); + } + } + } + buttons.push(); + return buttons; + }; + + return ( + <> + {showInvite && ( + + )} + {showReturn && ( + + )} + 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; };