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()}
    ); }