feat: minihra Tanky
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 30s
CI / Build client (push) Successful in 46s
CI / Playwright E2E tests (push) Successful in 1m57s
CI / Build and push Docker image (push) Successful in 46s
CI / Notify (push) Successful in 2s

Nová skrytá minihra (kód "tanky") pro více hráčů, simulaci drží server:
- jízda šipkami/WSAD, otočná věž míří za myší, střelba klikem nebo mezerníkem
- režimy poslední přeživší (výchozí) a na čas, volitelně ve dvou týmech
- volitelné odrážení střel od zdí (výchozí vypnuto)
- hardcore režim: tma, vidět je jen světlo kolem tanku a reflektor ve směru věže
- bonusy: štít, trojitá střela, rakety, turbo, neviditelnost, naváděné střely, miny, život
- 7 map v pěti prostředích (město, les, poušť, zima, bažina) s vodou,
  zpomalujícím terénem a zničitelnými bednami
- boti se třemi obtížnostmi (hledání cesty, rozstřelování beden, v hardcore vidí jen světlo)
- efekty: záblesk výstřelu, výbuchy, hořící vraky

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hrubyjarandClaude Opus 5.5 committed 2026-10-20 15:58:47 +02:00
1 parent d2125a050b
commit 96dc3f0053
10 files changed
+3413 -12

No files matched your search

+509
View File
@@ -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<TankState | null>(null);
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
const canvasRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<TankPhase>('idle');
const flashes = useRef<MuzzleFlash[]>([]);
const prevShots = useRef(new Map<string, number>());
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<string>();
let mouseFire = false;
let sent = '';
const any = (keys: Set<string>) => [...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) => <span className="tanks-dot" style={{ background: TEAM_COLORS[team][0] }} />;
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 && (
<div className="tanks-layout">
{state.teams && state.teamScores && (
<div className="tanks-teamscore">
<span style={{ color: TEAM_COLORS[0][0] }}>{TEAM_NAMES[0]}</span>
<strong>{state.teamScores[0]} : {state.teamScores[1]}</strong>
<span style={{ color: TEAM_COLORS[1][0] }}>{TEAM_NAMES[1]}</span>
</div>
)}
<div className="tanks-board-wrap">
<canvas ref={canvasRef} className="tanks-board" width={state.width} height={state.height} />
{phase === 'countdown' && <div className="tanks-overlay">{state.countdown}</div>}
{phase === 'playing' && me && !me.alive && me.respawnIn !== undefined && (
<div className="tanks-overlay small">Zničen — návrat za {Math.ceil(me.respawnIn / 1000)} s</div>
)}
{phase === 'playing' && me && !me.alive && me.respawnIn === undefined && (
<div className="tanks-overlay small">Vypadli jste — sledujete hru</div>
)}
{phase === 'finished' && <div className="tanks-overlay small">{winnerText()}</div>}
</div>
{inGame && me?.alive && (
<div className="tanks-equipment">
Výbava: {equipment(me).length ? equipment(me).join(' ') : <span className="text-muted">nic — sbírejte bonusy</span>}
</div>
)}
<div className="tanks-status">
{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')}
</div>
{renderScores()}
</div>
);
const renderScores = () => (
<Table size="sm" className="tanks-scores">
<thead>
<tr>
<th>#</th>
<th>Hráč</th>
{state?.mode === 'lastman' ? <th>Životy</th> : <th>Body</th>}
<th>Zničil(a)</th>
<th>Zničen(a)</th>
</tr>
</thead>
<tbody>
{ranked.map((p, i) => (
<tr key={p.login} className={state?.mode === 'lastman' && p.lives === 0 && inGame ? 'tanks-out' : undefined}>
<td>{phase === 'finished' ? (i < 3 ? MEDALS[i] : i + 1) : ''}</td>
<td>
<span className="tanks-dot" style={{ background: tankColor(p) }} />
{p.login}{p.login === login && ' (vy)'}
{state?.teams && p.team !== undefined && <span className="text-muted"> · {TEAM_NAMES[p.team]}</span>}
{p.alive && inGame && equipment(p).length > 0 && <span className="tanks-items"> {equipment(p).join(' ')}</span>}
</td>
<td>{state?.mode === 'lastman' ? '❤️'.repeat(p.lives ?? 0) || '💀' : p.score}</td>
<td>{p.kills}</td>
<td>{p.deaths}</td>
</tr>
))}
</tbody>
</Table>
);
const renderPlayer = (p: TankPlayer) => (
<li key={p.login}>
<span className="tanks-dot" style={{ background: tankColor(p) }} />
{p.login}{p.login === state?.host && ' 👑'}
{state?.teams && (isHost || p.login === login) && (
<Button size="sm" variant="link" className="tanks-switch" title="Přesunout do druhého týmu"
onClick={() => send('tanks:team', { target: p.login })}>⇄</Button>
)}
</li>
);
const renderLobby = () => state && (
<>
<p>Lobby založil(a) <strong>{state.host}</strong>. Hráči ({state.players.length}/{state.maxPlayers}):</p>
{state.teams ? (
<div className="tanks-teams">
{([0, 1] as const).map(team => {
const members = state.players.filter(p => p.team === team);
return (
<div key={team}>
<strong>{teamDot(team)}{TEAM_NAMES[team]}</strong> ({members.length}/{MAX_TEAM_SIZE})
<ul className="tanks-players">{members.map(renderPlayer)}</ul>
</div>
);
})}
</div>
) : (
<ul className="tanks-players two-columns">{state.players.map(renderPlayer)}</ul>
)}
{isHost ? (
<>
<div className="tanks-config">
<Form.Group>
<Form.Label>Režim</Form.Label>
<Form.Select size="sm" value={state.mode} onChange={e => send('tanks:configure', { mode: e.target.value })}>
{MODES.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
</Form.Select>
</Form.Group>
<Form.Group>
<Form.Label>Mapa</Form.Label>
<Form.Select size="sm" value={state.mapId} onChange={e => send('tanks:configure', { mapId: e.target.value })}>
<option value="random">{mapLabel()}</option>
{state.maps.map(m => <option key={m.id} value={m.id}>{mapLabel(m)}</option>)}
</Form.Select>
</Form.Group>
</div>
<div className="tanks-options">
<span className="tanks-option-label">Hráči</span>
<div>
<Form.Check inline type="radio" id="tanks-solo" name="tanks-teams" label="Každý sám za sebe"
checked={!state.teams} onChange={() => send('tanks:configure', { teams: false })} />
<Form.Check inline type="radio" id="tanks-teams" name="tanks-teams" label="Dva týmy"
checked={state.teams} onChange={() => send('tanks:configure', { teams: true })} />
</div>
<span className="tanks-option-label">Střely</span>
<Form.Check type="switch" id="tanks-bounce" label="Odráží se od zdí" checked={state.bounce}
onChange={e => send('tanks:configure', { bounce: e.target.checked })} />
<span className="tanks-option-label">Hardcore</span>
<Form.Check type="switch" id="tanks-hardcore" label="🌑 Tma — vidět je jen okolí tanku" checked={state.hardcore}
onChange={e => send('tanks:configure', { hardcore: e.target.checked })} />
{(state.players.length < state.maxPlayers || state.players.some(p => p.bot)) && (
<>
<span className="tanks-option-label">Boti</span>
<div className="tanks-bot-picker">
{state.players.length < state.maxPlayers && (
<>
<Form.Select size="sm" value={botLevel} onChange={e => setBotLevel(e.target.value as BotLevel)}>
{BOT_LEVELS.map(l => <option key={l.value} value={l.value}>{l.label}</option>)}
</Form.Select>
<Button size="sm" variant="outline-primary" onClick={() => send('tanks:addBot', { level: botLevel })}>
Přidat bota
</Button>
</>
)}
{state.players.some(p => p.bot) && (
<Button size="sm" variant="outline-secondary" onClick={() => send('tanks:removeBots')}>Odebrat boty</Button>
)}
</div>
</>
)}
</div>
</>
) : (
<p>
Režim: <strong>{mode.label}{state.teams && ' v týmech'}{state.bounce && ', odrazy'}{state.hardcore && ', 🌑 hardcore'}</strong>
{' · '}mapa: <strong>{mapName}</strong>
</p>
)}
<p className="text-muted small mb-0">
{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.'}
</p>
</>
);
const renderBody = () => {
switch (phase) {
case 'idle':
return (
<>
<p>
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.
</p>
<p className="mb-1">Bonusy:</p>
<ul className="tanks-pickups">
{Object.values(PICKUPS).map(p => <li key={p.icon}>{p.icon} {p.label}</li>)}
</ul>
</>
);
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(<Button key="new" onClick={() => send('tanks:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
}
if (phase === 'lobby') {
if (!isPlayer) {
buttons.push(<Button key="join" onClick={() => send('tanks:join')}
disabled={(state?.players.length ?? 0) >= (state?.maxPlayers ?? 0)}>Připojit se</Button>);
} else {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tanks:leave')}>
{isHost ? 'Zrušit lobby' : 'Odejít'}
</Button>);
if (isHost) {
buttons.push(<Button key="start" onClick={() => send('tanks:start')} disabled={startBlocked()}>Spustit hru</Button>);
}
}
}
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
return buttons;
};
return (
<>
{showInvite && (
<button className="duel-invite tanks-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na tanky — připojit se
</button>
)}
{showReturn && (
<button className="duel-invite tanks-invite" onClick={() => setOpen(true)}>
{icon} Tanky běží — zpět do hry
</button>
)}
<Modal show={open} onHide={() => setOpen(false)} size="xl" className="tanks-game-modal">
<Modal.Header closeButton>
<Modal.Title><h2>{icon} Tanky</h2></Modal.Title>
</Modal.Header>
<Modal.Body>
{renderBody()}
{error && <div className="text-danger mt-2">{error}</div>}
</Modal.Body>
<Modal.Footer>{renderFooter()}</Modal.Footer>
</Modal>
</>
);
}