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 330 additions and 146 deletions

No files matched your search

+29 -1
View File
@@ -3,10 +3,17 @@
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-board-wrap {
position: relative;
max-width: 960px;
max-width: 1120px;
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 {
text-align: center;
font-size: 1.1rem;
+69 -15
View File
@@ -31,13 +31,30 @@ const BOT_LEVELS: { value: BotLevel, label: string }[] = [
{ value: 'hard', label: 'Těžký' },
];
const FORWARD_KEYS = new Set(['ArrowUp', 'KeyW']);
const BACK_KEYS = new Set(['ArrowDown', 'KeyS']);
/** Směrové: klávesa = směr jízdy na mapě; tankové: nahoru/dolů jízda vpřed/vzad, doleva/doprava otáčení. */
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 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 GAME_KEYS = new Set([...UP_KEYS, ...DOWN_KEYS, ...LEFT_KEYS, ...RIGHT_KEYS, ...FIRE_KEYS, ...MINE_KEYS]);
const formatTime = (ms: number) => {
const s = Math.ceil(ms / 1000);
@@ -66,6 +83,7 @@ export default function TankGame() {
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [botLevel, setBotLevel] = useState<BotLevel>('medium');
const [controls, setControls] = useState<Controls>(getInitialControls);
const canvasRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<TankPhase>('idle');
const flashes = useRef<MuzzleFlash[]>([]);
@@ -129,11 +147,10 @@ export default function TankGame() {
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 x = (any(RIGHT_KEYS) ? 1 : 0) - (any(LEFT_KEYS) ? 1 : 0);
const y = (any(DOWN_KEYS) ? 1 : 0) - (any(UP_KEYS) ? 1 : 0);
const fire = any(FIRE_KEYS) || mouseFire;
const input = controls === 'tank' ? { drive: -y, turn: x, fire } : { dx: x, dy: y, fire };
const key = JSON.stringify(input);
if (key === sent) return;
sent = key;
@@ -198,7 +215,16 @@ export default function TankGame() {
window.removeEventListener('contextmenu', onContextMenu);
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
useEffect(() => {
@@ -242,9 +268,16 @@ export default function TankGame() {
}, [state]);
useEffect(() => {
if (canvasRef.current && state && (inGame || phase === 'finished')) {
drawTanks(canvasRef.current, state, flashes.current, login);
}
if (!state || !(inGame || phase === 'finished')) return;
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]);
if (!login) return null;
@@ -296,13 +329,32 @@ export default function TankGame() {
<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'
? ` · ${CONTROLS.find(c => c.value === controls)!.hint}, myš míření, klik / mezerník střelba, E / pravé tlačítko mina`
: ' · sledujete hru')}
</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()}
</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 = () => (
<Table size="sm" className="tanks-scores">
<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.teams ? ' Co osvítí spoluhráč, vidíte také.' : '') + ' Výbuchy a výstřely prosvítí i tmou.'}
</p>
{isPlayer && renderControls()}
{isPlayer && <LobbyChat game="tanks" />}
</>
);
@@ -437,8 +490,8 @@ export default function TankGame() {
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
Tanková bitva pro více hráčů. Jezdí se šipkami nebo WSAD (směrově, nebo po tankovém —
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ší
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.
@@ -447,6 +500,7 @@ export default function TankGame() {
<ul className="tanks-pickups">
{Object.values(PICKUPS).map(p => <li key={p.icon}>{p.icon} {p.label}</li>)}
</ul>
{renderControls()}
</>
);
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 }> = {
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í' },
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' },
turbo: { icon: '💨', label: 'turbo — rychlejší jízda' },
ghost: { icon: '👻', label: 'neviditelnost — soupeř vás uvidí jen zblízka' },
homing: { icon: '🧲', label: 'naváděné střely — stáčí se k soupeři' },
mine: { icon: '💣', label: 'miny — položíte E / pravým tlačítkem' },
heart: { icon: '❤️', label: 'život navíc (jen na přežití)' },
};
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 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);
}
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 ---
/** 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) {
const color = tankColor(p);
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) {
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.fillStyle = 'rgba(250, 204, 21, 0.18)';
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.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.setLineDash([]);
ctx.lineDashOffset = 0;
}
// Ochrana po zrození — tank bliká
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: 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 😉"
]
+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ů"
]
+144 -114
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) {
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 (!p.input.drive) return;
let speed = p.input.drive > 0 ? TANK_SPEED : TANK_REVERSE_SPEED;
}
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);