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>
This commit is contained in:
hrubyjarandClaude Opus 5.5 committed 2026-10-21 15:09:09 +02:00
1 parent c8132c283c
commit 16cf680183
6 files changed
+330 -146

No files matched your search

+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“",
"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 COLS = 24;
export const ROWS = 15;
export const COLS = 28;
export const ROWS = 17;
export const FIELD_WIDTH = COLS * TILE;
export const FIELD_HEIGHT = ROWS * TILE;
export const TICK_MS = 33;
@@ -127,21 +127,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Aréna',
biome: 'city',
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ě',
biome: 'city',
half: [
"S...#.......",
"..#.#.####..",
"..#......#..",
"..####.#.#..",
".......#....",
"##.+.###.+.#",
"....+.......",
"S.#####..+..",
"....+.......",
"##.+.###.+.#",
".......#....",
"..####.#.#..",
"..#......#..",
"..#.#.####..",
"S...#.......",
"S...#.........",
"..#.#.####....",
"..#......#..+.",
"..####.#.#....",
".......#...##.",
"##.+.###......",
"....+.....+...",
".#.......##...",
"S.#####...+...",
".#.......##...",
"....+.....+...",
"##.+.###......",
".......#...##.",
"..####.#.#....",
"..#......#..+.",
"..#.#.####....",
"S...#.........",
],
},
{
@@ -171,21 +175,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Sklad',
biome: 'city',
half: [
"S.....+.....",
".++.+++.++..",
".++.....++.+",
"....##......",
"+.+.##.+.+..",
"....+.......",
".#..+.###...",
"S...+...#..+",
".#..+.###...",
"....+.......",
"+.+.##.+.+..",
"....##......",
".++.....++.+",
".++.+++.++..",
"S.....+.....",
"S.....+.......",
".++.+++.++..+.",
".++.....++....",
"....##......+.",
"+.+.##.+.+....",
"....+......##.",
".#..+.###.....",
".#......#..+..",
"S...+...#.....",
".#......#..+..",
".#..+.###.....",
"....+......##.",
"+.+.##.+.+....",
"....##......+.",
".++.....++....",
".++.+++.++..+.",
"S.....+.......",
],
},
{
@@ -193,21 +199,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Les',
biome: 'forest',
half: [
"S..%%...#...",
"..%%%..##...",
"......%%...%",
"#..#.......%",
"##...%%.....",
"....%%%..#..",
".#......%%..",
"~~.~~~~.~~~~",
"..%%....#...",
"S.%%%.....+.",
"....##..%%..",
"#........%%.",
"##..%%..#...",
"...%%%..##..",
"S......+....",
"S..%%...#..#..",
"..%%%..##...%.",
"......%%...%%.",
"#..#.......%..",
"##...%%..#...#",
"....%%%..##...",
".#......%%..#.",
"~~.~~~~.~~~~.~",
"S.%%%.....+...",
"..%%..##..%%..",
"#.......#..%..",
"##..%%.....#..",
"...%%%..##....",
".#.....%%...#.",
"...##...%..~~.",
"..%%..#....~~.",
"S......+......",
],
},
{
@@ -215,21 +223,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Poušť',
biome: 'desert',
half: [
"S.....##....",
"...::.......",
"..::::..#...",
".......##...",
"#.........~~",
"##..+....~~~",
"......#..~~~",
"S.....#...~~",
"......#..~~~",
"##..+....~~~",
"#.........~~",
".......##...",
"..::::..#...",
"...::.......",
"S.....##....",
"S.....##......",
"...::.....#...",
"..::::..#...::",
".......##.....",
"#..#......~~..",
"##..+....~~~..",
"......#..~~~.#",
"..#...#...~~..",
"S.....#..+....",
"..#...#...~~..",
"......#..~~~.#",
"##..+....~~~..",
"#..#......~~..",
".......##.....",
"..::::..#...::",
"...::.....#...",
"S.....##......",
],
},
{
@@ -237,21 +247,23 @@ const MAP_HALVES: { id: string, name: string, biome: Biome, half: string[] }[] =
name: 'Zima',
biome: 'winter',
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',
biome: 'swamp',
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}). */
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 & {
input: Input,
@@ -639,12 +654,18 @@ export class TankGame {
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) {
const p = this.players.get(login);
if (!p || p.bot) return;
const data = (payload ?? {}) as { drive?: unknown, turn?: unknown, fire?: unknown };
p.input = { drive: axis(data.drive), turn: axis(data.turn), fire: data.fire === true };
const data = (payload ?? {}) as { drive?: unknown, turn?: unknown, fire?: unknown, dx?: unknown, dy?: unknown };
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. */
@@ -768,13 +789,22 @@ export class TankGame {
}
private moveTank(p: InternalPlayer, dt: number, now: number) {
p.angle = (p.angle + p.input.turn * TANK_TURN_SPEED * dt) % (Math.PI * 2);
if (!p.input.drive) return;
let speed = p.input.drive > 0 ? TANK_SPEED : TANK_REVERSE_SPEED;
let drive = p.input.drive;
if (p.input.dir) {
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;
const ground = this.tileAtPos(p.x, p.y);
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í
const nx = p.x + Math.cos(p.angle) * dist;
if (!this.tankBlocked(p, nx, p.y)) p.x = nx;
+28 -2
View File
@@ -1,6 +1,6 @@
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,
LAST_MAN_LIVES, MAX_PLAYERS, TANK_RADIUS, ROCKET_AMMO, MINE_AMMO, MINE_ARM_MS, FIELD_WIDTH,
} from '../tankGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
@@ -94,6 +94,32 @@ describe('tanky', () => {
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í', () => {
const ctx = setup();
ctx.startGame();
@@ -397,7 +423,7 @@ describe('tanky — týmy', () => {
const ctx = setup();
ctx.startGame('deathmatch', LOGINS, 'arena', true);
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
ctx.game.placeForTest('alice', 100, 60, 0);