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

+2
View File
@@ -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) {
<TetrisGame />
<PongGame />
<DrawingGame />
<TankGame />
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
+154
View File
@@ -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;
}
}
}
+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>
</>
);
}
+904
View File
@@ -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<PickupType, { icon: string, label: string }> = {
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<Biome, { icon: string }> = {
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<Biome, BiomeStyle> = {
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<Biome, { patch: string, blade: string, blade2: string }> = {
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;
}
+12 -12
View File
@@ -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<StyleTheme, Record<GameId, string>> = {
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 <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
+8
View File
@@ -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';