From 16cf6801834b3bcf495a8e1a5df315af33e96d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hrub=C3=BD=20Jaroslav?= Date: Wed, 21 Oct 2026 15:09:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20sm=C4=9Brov=C3=A9=20ovl=C3=A1d=C3=A1n?= =?UTF-8?q?=C3=AD=20tank=C5=AF,=20v=C4=9Bt=C5=A1=C3=AD=20mapy,=20zv=C3=BDr?= =?UTF-8?q?azn=C4=9Bn=C3=AD=20vlastn=C3=ADho=20tanku=20a=20legenda=20bonus?= =?UTF-8?q?=C5=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- client/src/components/TankGame.scss | 30 +++- client/src/components/TankGame.tsx | 84 +++++++-- client/src/games/tankRender.ts | 68 ++++++-- server/changelogs/2026-10-21.json | 4 +- server/src/tankGame.ts | 260 ++++++++++++++++------------ server/src/tests/tankGame.test.ts | 30 +++- 6 files changed, 330 insertions(+), 146 deletions(-) diff --git a/client/src/components/TankGame.scss b/client/src/components/TankGame.scss index 6f7e3bb..e0b3858 100644 --- a/client/src/components/TankGame.scss +++ b/client/src/components/TankGame.scss @@ -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; diff --git a/client/src/components/TankGame.tsx b/client/src/components/TankGame.tsx index 6643637..6f1f7de 100644 --- a/client/src/components/TankGame.tsx +++ b/client/src/components/TankGame.tsx @@ -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(null); const [botLevel, setBotLevel] = useState('medium'); + const [controls, setControls] = useState(getInitialControls); const canvasRef = useRef(null); const prevPhase = useRef('idle'); const flashes = useRef([]); @@ -129,11 +147,10 @@ export default function TankGame() { let sent = ''; const any = (keys: Set) => [...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() {
{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')}
+ {inGame && isPlayer && renderControls()} + {inGame && ( +
    + {(Object.entries(PICKUPS) as [PickupType, typeof PICKUPS[PickupType]][]) + .filter(([type]) => type !== 'heart' || state.mode === 'lastman') + .map(([type, p]) =>
  • {p.icon} {p.label}
  • )} +
+ )} {renderScores()} ); + /** Volba ovládání — osobní nastavení hráče, pamatuje si ho prohlížeč. */ + const renderControls = () => ( +
+ Ovládání + {CONTROLS.map(c => ( + changeControls(c.value)} /> + ))} +
+ ); + const renderScores = () => ( @@ -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.'}

+ {isPlayer && renderControls()} {isPlayer && } ); @@ -437,8 +490,8 @@ export default function TankGame() { return ( <>

- 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() {

    {Object.values(PICKUPS).map(p =>
  • {p.icon} {p.label}
  • )}
+ {renderControls()} ); case 'lobby': diff --git a/client/src/games/tankRender.ts b/client/src/games/tankRender.ts index 40aab03..601b905 100644 --- a/client/src/games/tankRender.ts +++ b/client/src/games/tankRender.ts @@ -84,13 +84,13 @@ export const TEAM_NAMES = ['Červení', 'Modří']; export const PICKUPS: Record = { 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 = { @@ -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; diff --git a/server/changelogs/2026-10-21.json b/server/changelogs/2026-10-21.json index 7e877a6..f176d97 100644 --- a/server/changelogs/2026-10-21.json +++ b/server/changelogs/2026-10-21.json @@ -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ů" ] diff --git a/server/src/tankGame.ts b/server/src/tankGame.ts index e994302..e069938 100644 --- a/server/src/tankGame.ts +++ b/server/src/tankGame.ts @@ -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; diff --git a/server/src/tests/tankGame.test.ts b/server/src/tests/tankGame.test.ts index 278d604..019e836 100644 --- a/server/src/tests/tankGame.test.ts +++ b/server/src/tests/tankGame.test.ts @@ -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);