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
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:
1 parent
d2125a050b
commit
96dc3f0053
10 files changed
+3413
-12
No files matched your search
@@ -7,6 +7,7 @@ import SnakeGame from "./SnakeGame";
|
|||||||
import TetrisGame from "./TetrisGame";
|
import TetrisGame from "./TetrisGame";
|
||||||
import PongGame from "./PongGame";
|
import PongGame from "./PongGame";
|
||||||
import DrawingGame from "./DrawingGame";
|
import DrawingGame from "./DrawingGame";
|
||||||
|
import TankGame from "./TankGame";
|
||||||
import { useMinigameSecret } from "../hooks/useMinigameSecret";
|
import { useMinigameSecret } from "../hooks/useMinigameSecret";
|
||||||
import { useSettings, ThemePreference } from "../context/settings";
|
import { useSettings, ThemePreference } from "../context/settings";
|
||||||
import HuePicker from "./HuePicker";
|
import HuePicker from "./HuePicker";
|
||||||
@@ -209,6 +210,7 @@ export default function Header({ choices, dayIndex }: Props) {
|
|||||||
<TetrisGame />
|
<TetrisGame />
|
||||||
<PongGame />
|
<PongGame />
|
||||||
<DrawingGame />
|
<DrawingGame />
|
||||||
|
<TankGame />
|
||||||
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
<SettingsModal isOpen={settingsModalOpen} onClose={closeSettingsModal} onSave={saveSettings} />
|
||||||
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
<RefreshMenuModal isOpen={refreshMenuModalOpen} onClose={closeRefreshMenuModal} />
|
||||||
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
<PizzaCalculatorModal isOpen={pizzaModalOpen} onClose={closePizzaModal} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import { StyleTheme, useSettings } from "../context/settings";
|
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. */
|
/** Ikony miniher podle vizuálního stylu aplikace. */
|
||||||
const ICONS: Record<StyleTheme, Record<GameId, string>> = {
|
const ICONS: Record<StyleTheme, Record<GameId, string>> = {
|
||||||
simple: { reaction: '⚡', snake: '🐍', tetris: '🧱', pong: '🏓', drawing: '🎨' },
|
simple: { reaction: '⚡', snake: '🐍', tetris: '🧱', pong: '🏓', drawing: '🎨', tanks: '💥' },
|
||||||
// Dostihy: startovní vlajka, kůň místo hada, mrkve jako kostky, žokej, trofej
|
// Dostihy: startovní vlajka, kůň místo hada, mrkve jako kostky, žokej, trofej, terč
|
||||||
umamusume: { reaction: '🏁', snake: '🐎', tetris: '🥕', pong: '🏇', drawing: '🏆' },
|
umamusume: { reaction: '🏁', snake: '🐎', tetris: '🥕', pong: '🏇', drawing: '🏆', tanks: '🎯' },
|
||||||
// Duha, jednorožec, drahokamy, balónky a paleta
|
// Duha, jednorožec, drahokamy, balónky, paleta a blesk Rainbow Dash
|
||||||
mlp: { reaction: '🌈', snake: '🦄', tetris: '💎', pong: '🎈', drawing: '🖌️' },
|
mlp: { reaction: '🌈', snake: '🦄', tetris: '💎', pong: '🎈', drawing: '🖌️', tanks: '⚡' },
|
||||||
// Pekelná korespondence: plamen, rudá nit, lampion, slaměná panenka, svitek
|
// Pekelná korespondence: plamen, rudá nit, lampion, slaměná panenka, svitek, svíčka
|
||||||
hellgirl: { reaction: '🔥', snake: '🪢', tetris: '🏮', pong: '🪆', drawing: '📜' },
|
hellgirl: { reaction: '🔥', snake: '🪢', tetris: '🏮', pong: '🪆', drawing: '📜', tanks: '🕯️' },
|
||||||
// Klub lehké hudby: bicí Ritsu, želva Ton-chan, dortíky k čaji, kytara Gitah a notový zápis
|
// 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: '🎼' },
|
kon: { reaction: '🥁', snake: '🐢', tetris: '🍰', pong: '🎸', drawing: '🎼', tanks: '🎹' },
|
||||||
// Kessoku Band: mikrofon Kity, kabel od kytary, krabice od manga, bicí Nijiky a sešit s texty Bocchi
|
// 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: '📝' },
|
bocchi: { reaction: '🎤', snake: '🔌', tetris: '📦', pong: '🥁', drawing: '📝', tanks: '🎸' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Styl z <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
|
/** Styl z <html data-luncher-theme> — pro místa mimo React kontext nastavení. */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { openSnakeGame } from "../components/SnakeGame";
|
|||||||
import { openTetrisGame } from "../components/TetrisGame";
|
import { openTetrisGame } from "../components/TetrisGame";
|
||||||
import { openPongGame } from "../components/PongGame";
|
import { openPongGame } from "../components/PongGame";
|
||||||
import { openDrawingGame } from "../components/DrawingGame";
|
import { openDrawingGame } from "../components/DrawingGame";
|
||||||
|
import { openTankGame } from "../components/TankGame";
|
||||||
import { GameId, gameIcon } from "../games/themeIcons";
|
import { GameId, gameIcon } from "../games/themeIcons";
|
||||||
|
|
||||||
type Minigame = {
|
type Minigame = {
|
||||||
@@ -52,6 +53,13 @@ const MINIGAMES: Minigame[] = [
|
|||||||
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
|
description: 'Jeden kreslí jídlo z dnešních menu, ostatní hádají.',
|
||||||
open: openDrawingGame,
|
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';
|
const HELP_CODE = 'help';
|
||||||
|
|||||||
@@ -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: 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",
|
"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"
|
"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 😉"
|
||||||
]
|
]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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<typeof setup>, login: string, type: Parameters<TankGame['addPickupForTest']>[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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { registerSnakeGame } from "./snakeGame";
|
|||||||
import { registerTetrisGame } from "./tetrisGame";
|
import { registerTetrisGame } from "./tetrisGame";
|
||||||
import { registerPongGame } from "./pongGame";
|
import { registerPongGame } from "./pongGame";
|
||||||
import { registerDrawingGame } from "./drawingGame";
|
import { registerDrawingGame } from "./drawingGame";
|
||||||
|
import { registerTankGame } from "./tankGame";
|
||||||
|
|
||||||
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
||||||
let pubClient: ReturnType<typeof createClient>;
|
let pubClient: ReturnType<typeof createClient>;
|
||||||
@@ -38,6 +39,7 @@ export const initWebsocket = (server: any) => {
|
|||||||
registerTetrisGame(io);
|
registerTetrisGame(io);
|
||||||
registerPongGame(io);
|
registerPongGame(io);
|
||||||
registerDrawingGame(io);
|
registerDrawingGame(io);
|
||||||
|
registerTankGame(io);
|
||||||
return io;
|
return io;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user