3 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
Vít Kilián 2a5eb07778 feat: chat v lobby
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 27s
CI / Build server (push) Successful in 31s
CI / Build client (push) Successful in 49s
CI / Playwright E2E tests (push) Successful in 2m35s
CI / Build and push Docker image (push) Successful in 1m4s
CI / Notify (push) Successful in 2s
2026-10-21 11:52:08 +02:00
16 changed files with 459 additions and 147 deletions

No files matched your search

Binary file not shown.
+35
View File
@@ -0,0 +1,35 @@
.lobby-chat {
margin-top: 12px;
.lobby-chat-messages {
height: 140px;
overflow-y: auto;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 6px;
padding: 6px 8px;
margin-bottom: 6px;
font-size: 0.9rem;
word-break: break-word;
}
.lobby-chat-emojis {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-bottom: 4px;
button {
border: none;
background: none;
padding: 0 3px;
font-size: 1.2rem;
line-height: 1.4;
border-radius: 4px;
cursor: pointer;
&:hover {
background: rgba(127, 127, 127, 0.2);
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { Form } from "react-bootstrap";
import { socket } from "../context/socket";
import { getToken } from "../Utils";
import "./LobbyChat.scss";
const EMOJIS = ['😀', '😂', '😎', '😢', '😡', '👍', '👎', '👋', '🎉', '🔥', '❤️', '🍕', '🍔', '🍺', '⏳'];
type LobbyChatMessage = { game: string, login: string, text: string };
/** Jednoduchý chat v lobby minihry — zprávy se drží jen po dobu zobrazení lobby. */
export default function LobbyChat({ game }: { game: string }) {
const [messages, setMessages] = useState<LobbyChatMessage[]>([]);
const [text, setText] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const onMessage = (m: LobbyChatMessage) => {
if (m.game === game) setMessages(prev => [...prev.slice(-99), m]);
};
socket.on('lobbychat:message', onMessage);
return () => { socket.off('lobbychat:message', onMessage); };
}, [game]);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [messages]);
const submit = (e: FormEvent) => {
e.preventDefault();
if (!text.trim()) return;
socket.emit('lobbychat:send', { token: getToken(), game, text });
setText('');
};
const addEmoji = (emoji: string) => {
setText(prev => (prev + emoji).slice(0, 200));
inputRef.current?.focus();
};
return (
<div className="lobby-chat">
<div className="lobby-chat-messages" ref={listRef}>
{messages.length === 0 && <div className="text-muted">Zatím žádné zprávy…</div>}
{messages.map((m, i) => <div key={i}><strong>{m.login}: </strong>{m.text}</div>)}
</div>
<div className="lobby-chat-emojis">
{EMOJIS.map(e => <button key={e} type="button" onClick={() => addEmoji(e)}>{e}</button>)}
</div>
<Form onSubmit={submit}>
<Form.Control
ref={inputRef}
size="sm"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Napište zprávu…"
maxLength={200}
onKeyDown={e => e.stopPropagation()}
/>
</Form>
</div>
);
}
+2
View File
@@ -5,6 +5,7 @@ import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import { useTouchDevice } from "../hooks/useTouchDevice";
import LobbyChat from "./LobbyChat";
import "./PongGame.scss";
// Typy musí odpovídat server/src/pongGame.ts (stav chodí přes websocket, ne přes OpenAPI)
@@ -257,6 +258,7 @@ export default function PongGame() {
{login === state?.host && state?.players.some(p => p.bot) && (
<Button size="sm" variant="outline-secondary" onClick={() => send('pong:removeBot')}>Odebrat bota</Button>
)}
{isPlayer && <LobbyChat game="pong" />}
</>
);
default:
+2
View File
@@ -5,6 +5,7 @@ import { useAuth } from "../context/auth";
import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import { useTouchDevice } from "../hooks/useTouchDevice";
import LobbyChat from "./LobbyChat";
import "./ReactionDuel.scss";
// Typy musí odpovídat server/src/reactionDuel.ts (stav chodí přes websocket, ne přes OpenAPI)
@@ -183,6 +184,7 @@ export default function ReactionDuel() {
</ul>
{state && state.players.length < state.minPlayers &&
<p className="text-muted">Čeká se na další hráče…</p>}
{isPlayer && <LobbyChat game="duel" />}
</>
);
case 'waiting':
+2
View File
@@ -8,6 +8,7 @@ import { getToken } from "../Utils";
import { useGameIcon } from "../games/themeIcons";
import { useTouchDevice } from "../hooks/useTouchDevice";
import TouchButton from "./TouchButton";
import LobbyChat from "./LobbyChat";
import "./SnakeGame.scss";
// Typy musí odpovídat server/src/snakeGame.ts (stav chodí přes websocket, ne přes OpenAPI)
@@ -279,6 +280,7 @@ export default function SnakeGame() {
</li>
))}
</ul>
{isPlayer && <LobbyChat game="snake" />}
</>
);
case 'countdown':
+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;
+71 -15
View File
@@ -8,6 +8,7 @@ import {
BIOMES, BotLevel, drawTanks, FLASH_MS, MuzzleFlash, PICKUPS, PickupType, tankColor, TankMode, TankPhase,
TankPlayer, TankState, TEAM_COLORS, TEAM_NAMES,
} from "../games/tankRender";
import LobbyChat from "./LobbyChat";
import "./TankGame.scss";
const OPEN_EVENT = 'luncher:open-tanks';
@@ -30,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);
@@ -65,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[]>([]);
@@ -128,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;
@@ -197,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(() => {
@@ -241,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;
@@ -295,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>
@@ -426,6 +479,8 @@ 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" />}
</>
);
@@ -435,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.
@@ -445,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':
+2
View File
@@ -9,6 +9,7 @@ import { TetrisSession, WIDTH, HEIGHT, pieceCells, Piece } from "../games/tetris
import { useGameIcon } from "../games/themeIcons";
import { useTouchDevice } from "../hooks/useTouchDevice";
import TouchButton from "./TouchButton";
import LobbyChat from "./LobbyChat";
import "./TetrisGame.scss";
// Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI)
@@ -554,6 +555,7 @@ export default function TetrisGame() {
{state && state.players.length < state.minPlayers &&
<p className="text-muted">Čeká se na další hráče… Pro hru o samotě použijte Trénink.</p>}
{renderSettings()}
{isPlayer && <LobbyChat game="tetris" />}
</>
);
case 'countdown':
+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 😉"
]
+4 -1
View File
@@ -1,3 +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 😉",
"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ů"
]
+17 -1
View File
@@ -1,4 +1,4 @@
import { Socket } from "socket.io";
import { Server, Socket } from "socket.io";
import { getLogin } from "./auth";
/** Sdílené pomůcky pro websocketové minihry (reakční hra, had). */
@@ -60,3 +60,19 @@ export function gameHandler(socket: Socket, errorEvent: string, action: (login:
/** Minimální počet hráčů — lokálně lze hrát i sám. */
export const minPlayersForEnv = (prod: number) => process.env.NODE_ENV === 'development' ? 1 : prod;
/**
* Jednoduchý chat v lobby miniher — zprávy se nikde neukládají, server je jen
* přepošle všem (klient si je filtruje podle hry).
*/
export function registerLobbyChat(io: Server) {
io.on('connection', socket => {
socket.on('lobbychat:send', gameHandler(socket, 'lobbychat:error', (login, payload) => {
const { game, text } = (payload ?? {}) as { game?: unknown, text?: unknown };
if (typeof game !== 'string' || typeof text !== 'string') return;
const trimmed = text.trim().slice(0, 200);
if (!trimmed) return;
io.emit('lobbychat:message', { game: game.slice(0, 20), login, text: trimmed });
}));
});
}
+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);
+2
View File
@@ -7,6 +7,7 @@ import { registerTetrisGame } from "./tetrisGame";
import { registerPongGame } from "./pongGame";
import { registerDrawingGame } from "./drawingGame";
import { registerTankGame } from "./tankGame";
import { registerLobbyChat } from "./minigames";
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
let pubClient: ReturnType<typeof createClient>;
@@ -40,6 +41,7 @@ export const initWebsocket = (server: any) => {
registerPongGame(io);
registerDrawingGame(io);
registerTankGame(io);
registerLobbyChat(io);
return io;
};