Compare commits

..
2 Commits
Author SHA1 Message Date
hrubyjarandClaude Opus 5.5 16cf680183 feat: směrové ovládání tanků, větší mapy, zvýraznění vlastního tanku a legenda bonusů
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 42s
CI / Playwright E2E tests (push) Successful in 2m0s
CI / Build and push Docker image (push) Successful in 49s
CI / Notify (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 15:09:09 +02:00
hrubyjarandClaude Opus 5.5 c8132c283c fix: chybějící čárka v changelogu 2026-10-20
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 15:09:09 +02:00
7 changed files with 331 additions and 147 deletions

No files matched your search

+29 -1
View File
@@ -3,10 +3,17 @@
bottom: 276px; bottom: 276px;
} }
// Hřiště je široké 1120 px — okno je o kus širší než běžné xl, ať se plocha nezmenšuje
@media (min-width: 1200px) {
.tanks-game-modal .modal-dialog {
--bs-modal-width: min(1200px, calc(100vw - 32px));
}
}
.tanks-game-modal { .tanks-game-modal {
.tanks-board-wrap { .tanks-board-wrap {
position: relative; position: relative;
max-width: 960px; max-width: 1120px;
margin: 0 auto; margin: 0 auto;
} }
@@ -92,6 +99,27 @@
} }
} }
.tanks-legend {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px 18px;
list-style: none;
padding: 0;
margin: 8px 0 0;
font-size: 0.8rem;
opacity: 0.8;
}
.tanks-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 4px 16px;
margin-top: 8px;
}
.tanks-equipment { .tanks-equipment {
text-align: center; text-align: center;
font-size: 1.1rem; font-size: 1.1rem;
+69 -15
View File
@@ -31,13 +31,30 @@ const BOT_LEVELS: { value: BotLevel, label: string }[] = [
{ value: 'hard', label: 'Těžký' }, { value: 'hard', label: 'Těžký' },
]; ];
const FORWARD_KEYS = new Set(['ArrowUp', 'KeyW']); /** Směrové: klávesa = směr jízdy na mapě; tankové: nahoru/dolů jízda vpřed/vzad, doleva/doprava otáčení. */
const BACK_KEYS = new Set(['ArrowDown', 'KeyS']); type Controls = 'directional' | 'tank';
const CONTROLS_KEY = 'tanks_controls';
const CONTROLS: { value: Controls, label: string, hint: string }[] = [
{ value: 'directional', label: 'Směrové', hint: 'šipky / WSAD jízda daným směrem' },
{ value: 'tank', label: 'Tankové', hint: '↑↓ / W S jízda, ←→ / A D otáčení' },
];
function getInitialControls(): Controls {
try {
return localStorage.getItem(CONTROLS_KEY) === 'tank' ? 'tank' : 'directional';
} catch {
// localStorage nedostupný
}
return 'directional';
}
const UP_KEYS = new Set(['ArrowUp', 'KeyW']);
const DOWN_KEYS = new Set(['ArrowDown', 'KeyS']);
const LEFT_KEYS = new Set(['ArrowLeft', 'KeyA']); const LEFT_KEYS = new Set(['ArrowLeft', 'KeyA']);
const RIGHT_KEYS = new Set(['ArrowRight', 'KeyD']); const RIGHT_KEYS = new Set(['ArrowRight', 'KeyD']);
const FIRE_KEYS = new Set(['Space']); const FIRE_KEYS = new Set(['Space']);
const MINE_KEYS = new Set(['KeyE']); const MINE_KEYS = new Set(['KeyE']);
const GAME_KEYS = new Set([...FORWARD_KEYS, ...BACK_KEYS, ...LEFT_KEYS, ...RIGHT_KEYS, ...FIRE_KEYS, ...MINE_KEYS]); const GAME_KEYS = new Set([...UP_KEYS, ...DOWN_KEYS, ...LEFT_KEYS, ...RIGHT_KEYS, ...FIRE_KEYS, ...MINE_KEYS]);
const formatTime = (ms: number) => { const formatTime = (ms: number) => {
const s = Math.ceil(ms / 1000); const s = Math.ceil(ms / 1000);
@@ -66,6 +83,7 @@ export default function TankGame() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [botLevel, setBotLevel] = useState<BotLevel>('medium'); const [botLevel, setBotLevel] = useState<BotLevel>('medium');
const [controls, setControls] = useState<Controls>(getInitialControls);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<TankPhase>('idle'); const prevPhase = useRef<TankPhase>('idle');
const flashes = useRef<MuzzleFlash[]>([]); const flashes = useRef<MuzzleFlash[]>([]);
@@ -129,11 +147,10 @@ export default function TankGame() {
let sent = ''; let sent = '';
const any = (keys: Set<string>) => [...pressed].some(k => keys.has(k)); const any = (keys: Set<string>) => [...pressed].some(k => keys.has(k));
const update = () => { const update = () => {
const input = { const x = (any(RIGHT_KEYS) ? 1 : 0) - (any(LEFT_KEYS) ? 1 : 0);
drive: (any(FORWARD_KEYS) ? 1 : 0) - (any(BACK_KEYS) ? 1 : 0), const y = (any(DOWN_KEYS) ? 1 : 0) - (any(UP_KEYS) ? 1 : 0);
turn: (any(RIGHT_KEYS) ? 1 : 0) - (any(LEFT_KEYS) ? 1 : 0), const fire = any(FIRE_KEYS) || mouseFire;
fire: any(FIRE_KEYS) || mouseFire, const input = controls === 'tank' ? { drive: -y, turn: x, fire } : { dx: x, dy: y, fire };
};
const key = JSON.stringify(input); const key = JSON.stringify(input);
if (key === sent) return; if (key === sent) return;
sent = key; sent = key;
@@ -198,7 +215,16 @@ export default function TankGame() {
window.removeEventListener('contextmenu', onContextMenu); window.removeEventListener('contextmenu', onContextMenu);
window.removeEventListener('blur', onBlur); window.removeEventListener('blur', onBlur);
}; };
}, [open, inGame, isPlayer, send]); }, [open, inGame, isPlayer, send, controls]);
const changeControls = (value: Controls) => {
setControls(value);
try {
localStorage.setItem(CONTROLS_KEY, value);
} catch {
// localStorage nedostupný
}
};
// Míření myší: kurzor nad hrací plochou natáčí věž, mimo ni se věž vrátí do směru jízdy // Míření myší: kurzor nad hrací plochou natáčí věž, mimo ni se věž vrátí do směru jízdy
useEffect(() => { useEffect(() => {
@@ -242,9 +268,16 @@ export default function TankGame() {
}, [state]); }, [state]);
useEffect(() => { useEffect(() => {
if (canvasRef.current && state && (inGame || phase === 'finished')) { if (!state || !(inGame || phase === 'finished')) return;
drawTanks(canvasRef.current, state, flashes.current, login); const draw = () => canvasRef.current && drawTanks(canvasRef.current, state, flashes.current, login);
} draw();
// Při odpočtu chodí stav jen jednou za sekundu — značka vlastního tanku se animuje překreslováním
if (phase !== 'countdown') return;
let frame = requestAnimationFrame(function loop() {
draw();
frame = requestAnimationFrame(loop);
});
return () => cancelAnimationFrame(frame);
}, [state, inGame, phase, login]); }, [state, inGame, phase, login]);
if (!login) return null; if (!login) return null;
@@ -296,13 +329,32 @@ export default function TankGame() {
<div className="tanks-status"> <div className="tanks-status">
{mode.label}{state.teams && ' v týmech'}{state.bounce && ' · odrazy'}{state.hardcore && ' · 🌑 hardcore'} · {mapName}{inGame && ` · zbývá ${formatTime(state.timeLeftMs)}`} {mode.label}{state.teams && ' v týmech'}{state.bounce && ' · odrazy'}{state.hardcore && ' · 🌑 hardcore'} · {mapName}{inGame && ` · zbývá ${formatTime(state.timeLeftMs)}`}
{inGame && (isPlayer {inGame && (isPlayer
? ' · ↑↓ / W S jízda, ←→ / A D otáčení, myš míření, klik / mezerník střelba, E / pravé tlačítko mina' ? ` · ${CONTROLS.find(c => c.value === controls)!.hint}, myš míření, klik / mezerník střelba, E / pravé tlačítko mina`
: ' · sledujete hru')} : ' · sledujete hru')}
</div> </div>
{inGame && isPlayer && renderControls()}
{inGame && (
<ul className="tanks-legend">
{(Object.entries(PICKUPS) as [PickupType, typeof PICKUPS[PickupType]][])
.filter(([type]) => type !== 'heart' || state.mode === 'lastman')
.map(([type, p]) => <li key={type}>{p.icon} {p.label}</li>)}
</ul>
)}
{renderScores()} {renderScores()}
</div> </div>
); );
/** Volba ovládání — osobní nastavení hráče, pamatuje si ho prohlížeč. */
const renderControls = () => (
<div className="tanks-controls">
<span className="tanks-option-label">Ovládání</span>
{CONTROLS.map(c => (
<Form.Check key={c.value} inline type="radio" id={`tanks-controls-${c.value}`} name="tanks-controls"
label={c.label} title={c.hint} checked={controls === c.value} onChange={() => changeControls(c.value)} />
))}
</div>
);
const renderScores = () => ( const renderScores = () => (
<Table size="sm" className="tanks-scores"> <Table size="sm" className="tanks-scores">
<thead> <thead>
@@ -427,6 +479,7 @@ export default function TankGame() {
{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.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.'} + (state.teams ? ' Co osvítí spoluhráč, vidíte také.' : '') + ' Výbuchy a výstřely prosvítí i tmou.'}
</p> </p>
{isPlayer && renderControls()}
{isPlayer && <LobbyChat game="tanks" />} {isPlayer && <LobbyChat game="tanks" />}
</> </>
); );
@@ -437,8 +490,8 @@ export default function TankGame() {
return ( return (
<> <>
<p> <p>
Tanková bitva pro více hráčů. Jezdí se šipkami nebo WSAD, věž míří za myší a střílí se Tanková bitva pro více hráčů. Jezdí se šipkami nebo WSAD (směrově, nebo po tankovém —
kliknutím nebo mezerníkem. Bedny se dají rozstřílet, přes vodu se nedá projet (ale dá se vpřed/vzad a otáčení), 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ší 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); 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. když není s kým hrát, přidejte si do lobby boty.
@@ -447,6 +500,7 @@ export default function TankGame() {
<ul className="tanks-pickups"> <ul className="tanks-pickups">
{Object.values(PICKUPS).map(p => <li key={p.icon}>{p.icon} {p.label}</li>)} {Object.values(PICKUPS).map(p => <li key={p.icon}>{p.icon} {p.label}</li>)}
</ul> </ul>
{renderControls()}
</> </>
); );
case 'lobby': case 'lobby':
+56 -12
View File
@@ -84,13 +84,13 @@ export const TEAM_NAMES = ['Červení', 'Modří'];
export const PICKUPS: Record<PickupType, { icon: string, label: string }> = { export const PICKUPS: Record<PickupType, { icon: string, label: string }> = {
shield: { icon: '🛡️', label: 'štít — vydrží jeden zásah' }, shield: { icon: '🛡️', label: 'štít — vydrží jeden zásah' },
triple: { icon: '🔱', label: 'trojitá střela' }, triple: { icon: '🔱', label: 'trojitá střela — tři střely do vějíře' },
rocket: { icon: '🚀', label: 'rakety — výbuch zasáhne i okolí' }, rocket: { icon: '🚀', label: 'rakety — výbuch zasáhne i okolí' },
turbo: { icon: '💨', label: 'turbo' }, turbo: { icon: '💨', label: 'turbo — rychlejší jízda' },
ghost: { icon: '👻', label: 'neviditelnost' }, ghost: { icon: '👻', label: 'neviditelnost — soupeř vás uvidí jen zblízka' },
homing: { icon: '🧲', label: 'naváděné střely' }, homing: { icon: '🧲', label: 'naváděné střely — stáčí se k soupeři' },
mine: { icon: '💣', label: 'miny (E / pravé tlačítko)' }, mine: { icon: '💣', label: 'miny — položíte E / pravým tlačítkem' },
heart: { icon: '❤️', label: 'život navíc' }, heart: { icon: '❤️', label: 'život navíc (jen na přežití)' },
}; };
export const BIOMES: Record<Biome, { icon: string }> = { export const BIOMES: Record<Biome, { icon: string }> = {
@@ -477,9 +477,48 @@ export function drawTanks(canvas: HTMLCanvasElement, state: TankState, flashes:
} }
for (const e of state.explosions) drawExplosion(ctx, e); for (const e of state.explosions) drawExplosion(ctx, e);
for (const p of tanks) drawLabel(ctx, p, p.login === me); for (const p of tanks) drawLabel(ctx, p, p.login === me);
// Při startu a po návratu do hry ukazuje na vlastní tank šipka s pulzujícím kruhem
if (self?.alive && (state.phase === 'countdown' || self.protected)) drawSelfMarker(ctx, self, now);
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.setTransform(1, 0, 0, 1, 0, 0);
} }
function drawSelfMarker(ctx: CanvasRenderingContext2D, p: TankPlayer, now: number) {
const pulse = (now % 900) / 900;
ctx.strokeStyle = `rgba(250, 204, 21, ${0.9 * (1 - pulse)})`;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(p.x, p.y, TANK_RADIUS + 10 + pulse * 40, 0, Math.PI * 2);
ctx.stroke();
// Šipka nad jmenovkou; u horního okraje hřiště pod tankem
const below = p.y < 70;
const dir = below ? -1 : 1;
const bob = Math.sin(now / 150) * 3;
const tipY = below ? p.y + TANK_RADIUS + 10 + bob : p.y - TANK_RADIUS - 20 + bob;
ctx.beginPath();
ctx.moveTo(p.x, tipY);
ctx.lineTo(p.x - 9, tipY - dir * 14);
ctx.lineTo(p.x - 4, tipY - dir * 14);
ctx.lineTo(p.x - 4, tipY - dir * 24);
ctx.lineTo(p.x + 4, tipY - dir * 24);
ctx.lineTo(p.x + 4, tipY - dir * 14);
ctx.lineTo(p.x + 9, tipY - dir * 14);
ctx.closePath();
ctx.lineWidth = 3;
ctx.strokeStyle = '#0b0f19';
ctx.stroke();
ctx.fillStyle = '#facc15';
ctx.fill();
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const textY = tipY - dir * 33;
ctx.lineWidth = 3;
ctx.strokeText('VY', p.x, textY);
ctx.fillText('VY', p.x, textY);
}
// --- Hardcore: světlo a tma --- // --- 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ě. */ /** Paprsek světla ze středu tanku do každého směru; délka končí na zdi či bedně. */
@@ -572,17 +611,22 @@ function drawDarkness(ctx: CanvasRenderingContext2D, state: TankState, lights: L
function drawTank(ctx: CanvasRenderingContext2D, p: TankPlayer, mine: boolean, now: number) { function drawTank(ctx: CanvasRenderingContext2D, p: TankPlayer, mine: boolean, now: number) {
const color = tankColor(p); const color = tankColor(p);
const base = ctx.globalAlpha; const base = ctx.globalAlpha;
// Vlastní tank má pod sebou světlý kruh, ať se v hemžení najde // Vlastní tank má pod sebou výrazný otáčející se kruh, ať se v hemžení najde (tmavý lem kvůli světlým mapám)
if (mine) { if (mine) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'; ctx.fillStyle = 'rgba(250, 204, 21, 0.18)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, TANK_RADIUS + 5, 0, Math.PI * 2); ctx.arc(p.x, p.y, TANK_RADIUS + 7, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.setLineDash([7, 5]);
ctx.lineDashOffset = -now / 40;
ctx.strokeStyle = 'rgba(11, 15, 25, 0.7)';
ctx.lineWidth = 4.5;
ctx.stroke();
ctx.strokeStyle = '#facc15';
ctx.lineWidth = 2.5;
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
ctx.lineDashOffset = 0;
} }
// Ochrana po zrození — tank bliká // Ochrana po zrození — tank bliká
if (p.protected && Math.floor(now / 120) % 2 === 0) ctx.globalAlpha = base * 0.35; if (p.protected && Math.floor(now / 120) % 2 === 0) ctx.globalAlpha = base * 0.35;
+1 -1
View File
@@ -1,6 +1,6 @@
[ [
"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 😉" "Přibyla další minihra — tentokrát se bude střílet 😉"
] ]
+3 -1
View File
@@ -1,4 +1,6 @@
[ [
"U oběda v restauraci si můžete zadat cenu (ikonka cenovky u vaší volby) — promítne se do měsíčního přehledu stravování a předvyplní se i v „Zaplatit za všechny“", "U oběda v restauraci si můžete zadat cenu (ikonka cenovky u vaší volby) — promítne se do měsíčního přehledu stravování a předvyplní se i v „Zaplatit za všechny“",
"V lobby miniher (Pong, Reakční hra, Had, Tetris, Tanky) si hráči mohou psát v chatu, emotikony se vkládají jedním kliknutím 😉" "V lobby miniher (Pong, Reakční hra, Had, Tetris, Tanky) si hráči mohou psát v chatu, emotikony se vkládají jedním kliknutím 😉",
"Tanky mají nově výchozí směrové ovládání — šipka nebo WSAD určuje směr jízdy na mapě; původní tankové ovládání (vpřed/vzad a otáčení) lze zapnout volbou Ovládání",
"Tanky: větší mapy s více překážkami, vlastní tank je výrazně označený (při startu a po návratu do hry na něj ukazuje šipka) a pod hrací plochou je legenda bonusů"
] ]
+145 -115
View File
@@ -20,8 +20,8 @@ import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } from ".
*/ */
export const TILE = 40; export const TILE = 40;
export const COLS = 24; export const COLS = 28;
export const ROWS = 15; export const ROWS = 17;
export const FIELD_WIDTH = COLS * TILE; export const FIELD_WIDTH = COLS * TILE;
export const FIELD_HEIGHT = ROWS * TILE; export const FIELD_HEIGHT = ROWS * TILE;
export const TICK_MS = 33; export const TICK_MS = 33;
@@ -127,21 +127,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Aréna', name: 'Aréna',
biome: 'city', biome: 'city',
half: [ half: [
"S..........+", "S..........++.",
"............", "..............",
"..##........", "..##..........",
"..#.....#...", "..#.....#.....",
"........#...", "........#..+..",
".....+......", ".....+........",
"....##......", "....##....#...",
"S..........+", "..........#...",
"....##......", "S......+......",
".....+......", "..........#...",
"........#...", "....##....#...",
"..#.....#...", ".....+........",
"..##........", "........#..+..",
"............", "..#.....#.....",
"S..........+", "..##..........",
"..............",
"S..........++.",
], ],
}, },
{ {
@@ -149,21 +151,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Bludiště', name: 'Bludiště',
biome: 'city', biome: 'city',
half: [ half: [
"S...#.......", "S...#.........",
"..#.#.####..", "..#.#.####....",
"..#......#..", "..#......#..+.",
"..####.#.#..", "..####.#.#....",
".......#....", ".......#...##.",
"##.+.###.+.#", "##.+.###......",
"....+.......", "....+.....+...",
"S.#####..+..", ".#.......##...",
"....+.......", "S.#####...+...",
"##.+.###.+.#", ".#.......##...",
".......#....", "....+.....+...",
"..####.#.#..", "##.+.###......",
"..#......#..", ".......#...##.",
"..#.#.####..", "..####.#.#....",
"S...#.......", "..#......#..+.",
"..#.#.####....",
"S...#.........",
], ],
}, },
{ {
@@ -171,21 +175,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Sklad', name: 'Sklad',
biome: 'city', biome: 'city',
half: [ half: [
"S.....+.....", "S.....+.......",
".++.+++.++..", ".++.+++.++..+.",
".++.....++.+", ".++.....++....",
"....##......", "....##......+.",
"+.+.##.+.+..", "+.+.##.+.+....",
"....+.......", "....+......##.",
".#..+.###...", ".#..+.###.....",
"S...+...#..+", ".#......#..+..",
".#..+.###...", "S...+...#.....",
"....+.......", ".#......#..+..",
"+.+.##.+.+..", ".#..+.###.....",
"....##......", "....+......##.",
".++.....++.+", "+.+.##.+.+....",
".++.+++.++..", "....##......+.",
"S.....+.....", ".++.....++....",
".++.+++.++..+.",
"S.....+.......",
], ],
}, },
{ {
@@ -193,21 +199,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Les', name: 'Les',
biome: 'forest', biome: 'forest',
half: [ half: [
"S..%%...#...", "S..%%...#..#..",
"..%%%..##...", "..%%%..##...%.",
"......%%...%", "......%%...%%.",
"#..#.......%", "#..#.......%..",
"##...%%.....", "##...%%..#...#",
"....%%%..#..", "....%%%..##...",
".#......%%..", ".#......%%..#.",
"~~.~~~~.~~~~", "~~.~~~~.~~~~.~",
"..%%....#...", "S.%%%.....+...",
"S.%%%.....+.", "..%%..##..%%..",
"....##..%%..", "#.......#..%..",
"#........%%.", "##..%%.....#..",
"##..%%..#...", "...%%%..##....",
"...%%%..##..", ".#.....%%...#.",
"S......+....", "...##...%..~~.",
"..%%..#....~~.",
"S......+......",
], ],
}, },
{ {
@@ -215,21 +223,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Poušť', name: 'Poušť',
biome: 'desert', biome: 'desert',
half: [ half: [
"S.....##....", "S.....##......",
"...::.......", "...::.....#...",
"..::::..#...", "..::::..#...::",
".......##...", ".......##.....",
"#.........~~", "#..#......~~..",
"##..+....~~~", "##..+....~~~..",
"......#..~~~", "......#..~~~.#",
"S.....#...~~", "..#...#...~~..",
"......#..~~~", "S.....#..+....",
"##..+....~~~", "..#...#...~~..",
"#.........~~", "......#..~~~.#",
".......##...", "##..+....~~~..",
"..::::..#...", "#..#......~~..",
"...::.......", ".......##.....",
"S.....##....", "..::::..#...::",
"...::.....#...",
"S.....##......",
], ],
}, },
{ {
@@ -237,21 +247,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Zima', name: 'Zima',
biome: 'winter', biome: 'winter',
half: [ half: [
"S...%%....#.", "S...%%....#...",
".::.%%..::..", ".::.%%..::..#.",
".::.....::..", ".::.....::....",
"....##......", "....##.....%%.",
"%%..##..~~..", "%%..##..~~....",
"%%......~~..", "%%......~~..#.",
"....::......", "...#::......#.",
"S..+::..##.+", "....::..##....",
"....::......", "S..+::..##.+..",
"%%......~~..", "....::........",
"%%..##..~~..", "...#::......#.",
"....##......", "%%......~~..#.",
".::.....::..", "%%..##..~~....",
".::.%%..::..", "....##.....%%.",
"S...%%....#.", ".::.....::....",
".::.%%..::..#.",
"S...%%....#...",
], ],
}, },
{ {
@@ -259,21 +271,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Bažina', name: 'Bažina',
biome: 'swamp', biome: 'swamp',
half: [ half: [
"S..::..%%...", "S..::..%%.....",
"..:::~~%%.::", "..:::~~%%.::#.",
".::.~~~...::", ".::.~~~...::..",
".....~..#...", ".....~..#.....",
"%%.......~~.", "%%.......~~.#.",
"%%%.::::.~~.", "%%%.::::.~~...",
"..+.::::....", "..+.::::....%%",
"S...::....+.", ".#........#.%%",
"..+.::::....", "S...::....+...",
"%%%.::::.~~.", ".#........#...",
"%%.......~~.", "..+.::::....%%",
".....~..#...", "%%%.::::.~~...",
".::.~~~...::", "%%.......~~.#.",
"..:::~~%%.::", ".....~..#.....",
"S..::..%%...", ".::.~~~...::..",
"..:::~~%%.::#.",
"S..::..%%.....",
], ],
}, },
]; ];
@@ -362,7 +376,8 @@ export type TankState = {
/** Nastavení hry z lobby (hodnoty přichází od klienta, ověřují se v {@link TankGame.configure}). */ /** Nastavení hry z lobby (hodnoty přichází od klienta, ověřují se v {@link TankGame.configure}). */
export type TankOptions = { mode?: unknown, mapId?: unknown, teams?: unknown, hardcore?: unknown, bounce?: unknown }; export type TankOptions = { mode?: unknown, mapId?: unknown, teams?: unknown, hardcore?: unknown, bounce?: unknown };
type Input = { drive: number, turn: number, fire: boolean }; /** Tankové ovládání (`drive` + `turn`), nebo směrové — `dir` je požadovaný směr jízdy na hřišti. */
type Input = { drive: number, turn: number, fire: boolean, dir?: [number, number] };
type InternalPlayer = TankPlayer & { type InternalPlayer = TankPlayer & {
input: Input, input: Input,
@@ -639,12 +654,18 @@ export class TankGame {
this.schedule(() => this.countdownStep(), 1000); this.schedule(() => this.countdownStep(), 1000);
} }
/** Stav ovládání hráče: drive −1/0/1 (couvání/stát/jízda), turn −1/0/1 (doleva/rovně/doprava), fire. */ /**
* Stav ovládání hráče: drive −1/0/1 (couvání/stát/jízda), turn −1/0/1 (doleva/rovně/doprava), fire.
* Směrové ovládání místo drive/turn posílá dx, dy −1/0/1 — tank se natočí a jede tím směrem.
*/
input(login: string, payload: unknown) { input(login: string, payload: unknown) {
const p = this.players.get(login); const p = this.players.get(login);
if (!p || p.bot) return; if (!p || p.bot) return;
const data = (payload ?? {}) as { drive?: unknown, turn?: unknown, fire?: unknown }; const data = (payload ?? {}) as { drive?: unknown, turn?: unknown, fire?: unknown, dx?: unknown, dy?: unknown };
p.input = { drive: axis(data.drive), turn: axis(data.turn), fire: data.fire === true }; const fire = data.fire === true;
p.input = 'dx' in data || 'dy' in data
? { drive: 0, turn: 0, fire, dir: [axis(data.dx), axis(data.dy)] }
: { drive: axis(data.drive), turn: axis(data.turn), fire };
} }
/** Míření myší: bod na hřišti, kam se má věž natočit; `null` vrátí věž do směru jízdy. */ /** Míření myší: bod na hřišti, kam se má věž natočit; `null` vrátí věž do směru jízdy. */
@@ -768,13 +789,22 @@ export class TankGame {
} }
private moveTank(p: InternalPlayer, dt: number, now: number) { private moveTank(p: InternalPlayer, dt: number, now: number) {
p.angle = (p.angle + p.input.turn * TANK_TURN_SPEED * dt) % (Math.PI * 2); let drive = p.input.drive;
if (!p.input.drive) return; if (p.input.dir) {
let speed = p.input.drive > 0 ? TANK_SPEED : TANK_REVERSE_SPEED; const [dx, dy] = p.input.dir;
if (!dx && !dy) return;
// Korba se rovnou otočí do zvoleného směru a tank jede
p.angle = Math.atan2(dy, dx);
drive = 1;
} else {
p.angle = (p.angle + p.input.turn * TANK_TURN_SPEED * dt) % (Math.PI * 2);
}
if (!drive) return;
let speed = drive > 0 ? TANK_SPEED : TANK_REVERSE_SPEED;
if (p.turboUntil > now) speed *= TURBO_FACTOR; if (p.turboUntil > now) speed *= TURBO_FACTOR;
const ground = this.tileAtPos(p.x, p.y); const ground = this.tileAtPos(p.x, p.y);
if (ground === ':' || ground === '%') speed *= MUD_FACTOR; if (ground === ':' || ground === '%') speed *= MUD_FACTOR;
const dist = p.input.drive * speed * dt; const dist = drive * speed * dt;
// Po osách zvlášť, aby tank klouzal podél zdí // Po osách zvlášť, aby tank klouzal podél zdí
const nx = p.x + Math.cos(p.angle) * dist; const nx = p.x + Math.cos(p.angle) * dist;
if (!this.tankBlocked(p, nx, p.y)) p.x = nx; if (!this.tankBlocked(p, nx, p.y)) p.x = nx;
+28 -2
View File
@@ -1,6 +1,6 @@
import { import {
TankGame, TankState, MAPS, COLS, ROWS, TICK_MS, COUNTDOWN_SECONDS, RESPAWN_MS, DEATHMATCH_MS, 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, LAST_MAN_LIVES, MAX_PLAYERS, TANK_RADIUS, ROCKET_AMMO, MINE_AMMO, MINE_ARM_MS, FIELD_WIDTH,
} from '../tankGame'; } from '../tankGame';
import { GameError } from '../minigames'; import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock'; import { fakeClock } from './helpers/fakeClock';
@@ -94,6 +94,32 @@ describe('tanky', () => {
expect(ctx.player('alice').angle).toBe(0); expect(ctx.player('alice').angle).toBe(0);
}); });
test('směrové ovládání jede rovnou zvoleným směrem', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 300, 300, 0);
ctx.game.input('alice', { dx: 0, dy: -1 });
ctx.ticks(10);
const p = ctx.player('alice');
expect(p.angle).toBeCloseTo(-Math.PI / 2, 2);
expect(p.x).toBeCloseTo(300, 0);
expect(p.y).toBeLessThan(300);
});
test('směrové ovládání bez stisknutého směru tank zastaví a nechá natočený', () => {
const ctx = setup();
ctx.startGame();
ctx.game.placeForTest('alice', 300, 300, 0);
ctx.game.input('alice', { dx: -1, dy: 0 });
ctx.ticks(1);
ctx.game.input('alice', { dx: 0, dy: 0 });
const { x, angle } = ctx.player('alice');
expect(x).toBeLessThan(300);
ctx.ticks(5);
expect(ctx.player('alice').angle).toBe(angle);
expect(ctx.player('alice').x).toBe(x);
});
test('zásah zničí soupeře, přidá bod a soupeř se po chvíli znovu objeví', () => { test('zásah zničí soupeře, přidá bod a soupeř se po chvíli znovu objeví', () => {
const ctx = setup(); const ctx = setup();
ctx.startGame(); ctx.startGame();
@@ -397,7 +423,7 @@ describe('tanky — týmy', () => {
const ctx = setup(); const ctx = setup();
ctx.startGame('deathmatch', LOGINS, 'arena', true); ctx.startGame('deathmatch', LOGINS, 'arena', true);
for (const p of ctx.last().players) { for (const p of ctx.last().players) {
expect(p.x < 480).toBe(p.team === 0); expect(p.x < FIELD_WIDTH / 2).toBe(p.team === 0);
} }
// alice a carol jsou tým 0, bob tým 1 // alice a carol jsou tým 0, bob tým 1
ctx.game.placeForTest('alice', 100, 60, 0); ctx.game.placeForTest('alice', 100, 60, 0);