feat: skryté multiplayerové minihry přes websocket
CI / Generate TypeScript types (push) Canceled after 7s
CI / Server unit tests (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Build client (push) Canceled after 0s
CI / Playwright E2E tests (push) Canceled after 0s
CI / Build and push Docker image (push) Canceled after 0s
CI / Notify (push) Canceled after 0s

Minihry se otevírají trojklikem na logo a napsáním kódu hry
("help" vypíše seznam do konzole prohlížeče):
- reakce: reakční hra, 3 kola, pořadí podle součtu časů
- snake: had pro více hráčů, simulaci drží server
- tetris: battle Tetris s odpadními řádky a lokálním tréninkem
- pong: Pong 1v1, ovládání myší i klávesami, bot se 4 obtížnostmi
- kresleni: kreslení a hádání jídel z dnešních menu

Ikony her se řídí zvoleným vzhledem. Stav her je v paměti serveru,
předpokládá se jedna replika.
This commit is contained in:
batmanisko committed 2026-10-12 14:25:45 +02:00
1 parent abeee19259
commit 6cb1214f08
28 files changed
+5050 -2

No files matched your search

+401
View File
@@ -0,0 +1,401 @@
import { Server } from "socket.io";
import { Clock, defaultClock, GameError, gameHandler, loginFromPayload, minPlayersForEnv } from "./minigames";
import { getActiveRestaurants, getRestaurantMenu } from "./service";
/**
* Kreslení — jeden hráč kreslí jídlo z dnešních menu, ostatní hádají v chatu.
*
* Každý hráč jednou kreslí. Hádá se klíčové slovo jídla (např. "guláš" u
* "Plzeňský hovězí guláš"), diakritika ani drobné koncovky nevadí. Kdo uhodne dřív,
* dostane víc bodů; kreslíř dostává body za každého, kdo uhodl.
*
* Hádané slovo dostává jen kreslíř (osobní místnost), ostatním chodí maska s nápovědou.
* Stav je v paměti, předpokládá jednu repliku.
*/
export const ROUND_MS = 80_000;
export const REVEAL_MS = 5000;
export const MAX_PLAYERS = 10;
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
export const GUESS_MAX_POINTS = 100;
export const GUESS_MIN_POINTS = 20;
export const DRAWER_POINTS_PER_GUESS = 25;
/** Podíly času kola, kdy se v nápovědě odkryje další písmeno. */
const HINT_REVEALS = [0.4, 0.7];
const MAX_MESSAGES = 30;
const MAX_MESSAGE_LENGTH = 100;
/** Pojistka proti nekonečné kresbě (součet bodů všech tahů v kole). */
const MAX_POINTS_PER_ROUND = 20_000;
export const WATCH_ROOM = 'draw-watchers';
export const userRoom = (login: string) => `draw-user:${login}`;
/** Záložní slova, když dnešní menu nejsou k dispozici (víkend, výpadek). */
export const FALLBACK_WORDS = [
'pizza', 'guláš', 'řízek', 'svíčková', 'burger', 'palačinky', 'knedlíky', 'špagety', 'sushi', 'kebab',
'salát', 'hranolky', 'párek', 'koláč', 'dort', 'bramboráky', 'rizoto', 'lasagne', 'tacos', 'vepřo knedlo zelo',
];
export type DrawPhase = 'idle' | 'lobby' | 'drawing' | 'reveal' | 'finished';
export type DrawPlayer = { login: string, score: number, guessed: boolean, place?: number };
export type ChatMessage = { login?: string, text: string, system?: boolean };
/** Tah štětcem; souřadnice 0–1000 nezávisle na velikosti plátna. */
export type Stroke = { color: string, size: number, points: [number, number][], start: boolean };
export type DrawState = {
phase: DrawPhase,
host?: string,
players: DrawPlayer[],
drawer?: string,
round: number,
totalRounds: number,
/** Maska hádaného jídla, např. "g _ _ _ š". */
hint?: string,
/** Řešení — jen ve fázi reveal a finished. */
answer?: string,
/** Zbývající čas kola (ms) v okamžiku odeslání. */
remainingMs?: number,
messages: ChatMessage[],
minPlayers: number,
};
type Word = { display: string, keywords: string[] };
/** Malá písmena bez diakritiky a nadbytečných mezer. */
export const normalize = (s: string) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/\s+/g, ' ').trim();
/**
* Z názvu jídla v menu udělá hádatelné slovo: bez alergenů, jen hlavní část
* (před čárkou a přílohou "s/se ..."), klíčová slova bez přídavných jmen.
*/
export function toWord(dish: string): Word {
let display = dish.replace(/\([^)]*\)/g, '').split(',')[0].split(/\s+(?:s|se|a|na)\s+/i)[0].replace(/\s+/g, ' ').trim();
const words = display.split(' ');
if (display.length > 30) display = words.slice(0, 3).join(' ');
// Přídavná jména v češtině končí obvykle na -ý/-á/-é/-í ("Plzeňský hovězí guláš" → guláš)
let keywords = display.split(' ').filter(w => w.length >= 4 && !/[ýáéí]$/i.test(w)).map(normalize);
if (keywords.length === 0) keywords = display.split(' ').filter(w => w.length >= 3).map(normalize);
if (keywords.length === 0) keywords = [normalize(display)];
return { display, keywords };
}
/** Uhodl hráč? Stačí jedno klíčové slovo; tolerují se koncovky (knedlík ~ knedlíky). */
export function isCorrectGuess(guess: string, word: Word): boolean {
const g = normalize(guess);
if (!g) return false;
if (g === normalize(word.display)) return true;
return g.split(' ').some(gw => word.keywords.some(kw => {
if (gw === kw) return true;
const need = Math.max(4, kw.length - 2);
return gw.length >= need && kw.slice(0, need) === gw.slice(0, need);
}));
}
export class DrawingGame {
private phase: DrawPhase = 'idle';
private host?: string;
private players: DrawPlayer[] = [];
private order: string[] = [];
private round = 0;
private word?: Word;
private words: Word[] = [];
private revealed = new Set<number>();
private roundEndsAt = 0;
private messages: ChatMessage[] = [];
private strokes: Stroke[] = [];
private strokePoints = 0;
private starting = false;
private timer: unknown;
private hintTimers: unknown[] = [];
constructor(
private readonly broadcastAll: (state: DrawState) => void,
private readonly sendWord: (login: string, word: string) => void,
private readonly relayStroke: (stroke: Stroke) => void,
private readonly relayClear: () => void,
private readonly loadDishes: () => Promise<string[]>,
private readonly minPlayers = 2,
private readonly clock: Clock = defaultClock,
) { }
get drawer(): string | undefined {
return this.phase === 'drawing' || this.phase === 'reveal' ? this.order[this.round - 1] : undefined;
}
getState(): DrawState {
const showAnswer = this.phase === 'reveal' || this.phase === 'finished';
return {
phase: this.phase,
host: this.host,
players: this.players.map(p => ({ ...p })),
drawer: this.drawer,
round: this.round,
totalRounds: this.order.length,
hint: this.phase === 'drawing' ? this.hint() : undefined,
answer: showAnswer ? this.word?.display : undefined,
remainingMs: this.phase === 'drawing' ? Math.max(0, this.roundEndsAt - this.clock.now()) : undefined,
messages: [...this.messages],
minPlayers: this.minPlayers,
};
}
getStrokes(): Stroke[] {
return this.strokes;
}
/** Hádané slovo pro daného hráče (jen kreslíř během kola). */
wordFor(login: string): string | undefined {
return this.phase === 'drawing' && login === this.drawer ? this.word?.display : undefined;
}
join(login: string) {
if (this.phase === 'idle' || this.phase === 'finished') {
this.reset();
this.phase = 'lobby';
this.host = login;
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
} else if (this.phase !== 'lobby') {
throw new GameError('Hra už běží, počkejte na další.');
}
if (this.players.some(p => p.login === login)) return;
if (this.players.length >= MAX_PLAYERS) {
throw new GameError('Lobby je plné.');
}
this.players.push({ login, score: 0, guessed: false });
this.emit();
}
leave(login: string) {
if (this.phase !== 'lobby' || !this.players.some(p => p.login === login)) return;
if (login === this.host) {
this.cancel();
return;
}
this.players = this.players.filter(p => p.login !== login);
this.emit();
}
async start(login: string) {
if (this.phase !== 'lobby' || this.starting) {
throw new GameError('Není otevřené žádné lobby.');
}
if (login !== this.host) {
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
}
if (this.players.length < this.minPlayers) {
throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
}
this.starting = true;
let dishes: string[] = [];
try {
dishes = await this.loadDishes();
} catch {
// Menu nejsou k dispozici — hraje se se záložními slovy
} finally {
this.starting = false;
}
if (this.phase !== 'lobby') return;
const unique = [...new Map(dishes.map(toWord).map(w => [normalize(w.display), w])).values()];
// Dnešní jídla mají přednost; když dojdou, doplní se záložní slova (viz nextRound)
this.words = unique.length > 0 ? unique : FALLBACK_WORDS.map(toWord);
this.order = this.players.map(p => p.login);
this.round = 0;
this.nextRound();
}
stroke(login: string, payload: unknown) {
if (this.phase !== 'drawing' || login !== this.drawer) return;
const { color, size, points, start } = (payload ?? {}) as Partial<Stroke>;
if (typeof color !== 'string' || !/^#[0-9a-f]{6}$/i.test(color)) return;
if (typeof size !== 'number' || size < 1 || size > 40) return;
if (!Array.isArray(points) || points.length === 0 || points.length > 200) return;
if (!points.every(p => Array.isArray(p) && p.length === 2 && p.every(v => Number.isFinite(v) && v >= 0 && v <= 1000))) return;
if (this.strokePoints + points.length > MAX_POINTS_PER_ROUND) return;
const stroke: Stroke = { color, size, points: points.map(([x, y]) => [Math.round(x), Math.round(y)]), start: !!start };
this.strokes.push(stroke);
this.strokePoints += points.length;
this.relayStroke(stroke);
}
clear(login: string) {
if (this.phase !== 'drawing' || login !== this.drawer) return;
this.strokes = [];
this.strokePoints = 0;
this.relayClear();
}
guess(login: string, text: unknown) {
if (typeof text !== 'string') return;
const message = text.trim().slice(0, MAX_MESSAGE_LENGTH);
const player = this.players.find(p => p.login === login);
if (!message || !player) return;
if (this.phase !== 'drawing') {
this.pushMessage({ login, text: message });
this.emit();
return;
}
// Kreslíř a ti, kdo už uhodli, by mohli prozradit řešení
if (login === this.drawer || player.guessed || !this.word) return;
if (isCorrectGuess(message, this.word)) {
const share = Math.max(0, this.roundEndsAt - this.clock.now()) / ROUND_MS;
player.guessed = true;
player.score += Math.max(GUESS_MIN_POINTS, Math.round(GUESS_MAX_POINTS * share));
const drawer = this.players.find(p => p.login === this.drawer);
if (drawer) drawer.score += DRAWER_POINTS_PER_GUESS;
this.pushMessage({ text: `${login} uhodl(a)! 🎉`, system: true });
const guessers = this.players.filter(p => p.login !== this.drawer);
if (guessers.every(p => p.guessed)) {
this.endRound();
return;
}
} else {
this.pushMessage({ login, text: message });
}
this.emit();
}
private nextRound() {
this.round++;
if (this.round > this.order.length) {
this.finish();
return;
}
const index = Math.floor(this.clock.random() * this.words.length);
this.word = this.words.splice(index, 1)[0] ?? toWord(FALLBACK_WORDS[0]);
if (this.words.length === 0) this.words = FALLBACK_WORDS.map(toWord);
this.revealed.clear();
this.strokes = [];
this.strokePoints = 0;
this.players.forEach(p => { p.guessed = false; });
this.phase = 'drawing';
this.roundEndsAt = this.clock.now() + ROUND_MS;
this.pushMessage({ text: `Kreslí ${this.drawer}.`, system: true });
this.relayClear();
this.schedule(() => this.endRound(), ROUND_MS);
this.hintTimers = HINT_REVEALS.map(share => this.clock.setTimeout(() => this.revealLetter(), ROUND_MS * share));
this.emit();
this.sendWord(this.drawer!, this.word.display);
}
private revealLetter() {
if (this.phase !== 'drawing' || !this.word) return;
const hidden = [...this.word.display].map((c, i) => i).filter(i => this.word!.display[i] !== ' ' && !this.revealed.has(i));
// Poslední písmeno nikdy neodkrýváme
if (hidden.length <= 1) return;
this.revealed.add(hidden[Math.floor(this.clock.random() * hidden.length)]);
this.emit();
}
private hint(): string {
if (!this.word) return '';
return [...this.word.display].map((c, i) => c === ' ' ? ' ' : this.revealed.has(i) ? c : '_').join(' ');
}
private endRound() {
this.clearHintTimers();
this.phase = 'reveal';
this.pushMessage({ text: `Bylo to: ${this.word?.display}`, system: true });
this.schedule(() => this.nextRound(), REVEAL_MS);
this.emit();
}
private finish() {
const ranked = [...this.players].sort((a, b) => b.score - a.score);
ranked.forEach((p, i) => { p.place = i + 1; });
this.phase = 'finished';
this.clearTimer();
this.emit();
}
private pushMessage(message: ChatMessage) {
this.messages.push(message);
if (this.messages.length > MAX_MESSAGES) this.messages.shift();
}
private cancel() {
this.reset();
this.emit();
}
private reset() {
this.clearTimer();
this.clearHintTimers();
this.phase = 'idle';
this.host = undefined;
this.players = [];
this.order = [];
this.round = 0;
this.word = undefined;
this.messages = [];
this.strokes = [];
this.strokePoints = 0;
}
private schedule(fn: () => void, ms: number) {
this.clearTimer();
this.timer = this.clock.setTimeout(() => {
this.timer = undefined;
fn();
}, ms);
}
private clearTimer() {
if (this.timer !== undefined) {
this.clock.clearTimeout(this.timer);
this.timer = undefined;
}
}
private clearHintTimers() {
this.hintTimers.forEach(t => this.clock.clearTimeout(t));
this.hintTimers = [];
}
private emit() {
this.broadcastAll(this.getState());
}
}
/** Názvy dnešních jídel (bez polévek) ze všech aktivních podniků. */
async function loadTodaysDishes(): Promise<string[]> {
const menus = await Promise.all(getActiveRestaurants().map(r => getRestaurantMenu(r).catch(() => undefined)));
return menus.flatMap(m => m?.food ?? []).filter(f => !f.isSoup && f.name).map(f => f.name);
}
/** Zaregistruje websocket handlery Kreslení. */
export function registerDrawingGame(io: Server) {
const game = new DrawingGame(
state => io.emit('draw:state', state),
(login, word) => io.to(userRoom(login)).emit('draw:word', word),
stroke => io.to(WATCH_ROOM).emit('draw:stroke', stroke),
() => io.to(WATCH_ROOM).emit('draw:clear'),
loadTodaysDishes,
minPlayersForEnv(2),
);
io.on('connection', socket => {
const handle = (action: (login: string, payload: unknown) => void | Promise<void>) => gameHandler(socket, 'draw:error', action);
socket.on('draw:sync', () => socket.emit('draw:state', game.getState()));
// Sledování vyžaduje přihlášení — kreslíř si tak po znovupřipojení vyzvedne své slovo
socket.on('draw:watch', payload => {
let login: string | undefined;
try { login = loginFromPayload(payload); } catch { /* sleduje anonymně */ }
socket.join(WATCH_ROOM);
if (login) socket.join(userRoom(login));
socket.emit('draw:state', game.getState());
socket.emit('draw:strokes', game.getStrokes());
const word = login ? game.wordFor(login) : undefined;
if (word) socket.emit('draw:word', word);
});
socket.on('draw:unwatch', () => {
socket.leave(WATCH_ROOM);
[...socket.rooms].filter(r => r.startsWith('draw-user:')).forEach(r => socket.leave(r));
});
socket.on('draw:join', handle(login => game.join(login)));
socket.on('draw:leave', handle(login => game.leave(login)));
socket.on('draw:start', handle(login => game.start(login)));
socket.on('draw:stroke', handle((login, payload) => game.stroke(login, payload)));
socket.on('draw:clear', handle(login => game.clear(login)));
socket.on('draw:guess', handle((login, payload) => game.guess(login, (payload as { text?: unknown })?.text)));
});
}
+62
View File
@@ -0,0 +1,62 @@
import { Socket } from "socket.io";
import { getLogin } from "./auth";
/** Sdílené pomůcky pro websocketové minihry (reakční hra, had). */
/** Chyba určená hráči — posílá se zpět klientovi, nic se neloguje. */
export class GameError extends Error { }
/** Hodiny a náhoda vyčleněné kvůli testům. */
export type Clock = {
now: () => number,
setTimeout: (fn: () => void, ms: number) => unknown,
clearTimeout: (handle: unknown) => void,
random: () => number,
};
export const defaultClock: Clock = {
now: () => Date.now(),
setTimeout: (fn, ms) => setTimeout(fn, ms),
clearTimeout: handle => clearTimeout(handle as NodeJS.Timeout),
random: () => Math.random(),
};
/** Ověří token z eventu a vrátí login, jinak vyhodí chybu. */
export function loginFromPayload(payload: unknown): string {
const token = (payload as { token?: unknown } | undefined)?.token;
if (typeof token !== 'string') {
throw new GameError('Chybí přihlášení.');
}
try {
const login = getLogin(token);
if (!login) throw new Error();
return login;
} catch {
throw new GameError('Neplatné přihlášení.');
}
}
/**
* Vytvoří handler websocket eventu, který ověří přihlášení a herní chyby
* pošle zpět jen danému klientovi jako `errorEvent`.
*/
export function gameHandler(socket: Socket, errorEvent: string, action: (login: string, payload: unknown) => void | Promise<void>) {
const onError = (e: unknown) => {
if (e instanceof GameError) {
socket.emit(errorEvent, e.message);
} else {
console.error(`Chyba minihry (${errorEvent})`, e);
}
};
return (payload: unknown) => {
try {
const result = action(loginFromPayload(payload), payload);
if (result instanceof Promise) result.catch(onError);
} catch (e) {
onError(e);
}
};
}
/** 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;
+429
View File
@@ -0,0 +1,429 @@
import { Server } from "socket.io";
import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
/**
* Pong 1v1 přes websocket.
*
* Server drží fyziku (míček, pálky, skóre) a posílá snímky sledujícím ~30× za sekundu;
* hráči posílají jen směr pálky (nahoru / dolů / stát). Kdo první dá {@link WIN_SCORE}
* bodů, vyhrává. Ostatní se mohou dívat. Stav je v paměti, předpokládá jednu repliku.
*
* Pálku lze ovládat klávesami (směr) i myší (cílová výška — pálka k ní rychle dojede).
* Zakladatel lobby může místo soupeře přidat bota s nastavitelnou obtížností.
*/
export const FIELD_WIDTH = 800;
export const FIELD_HEIGHT = 500;
export const PADDLE_HEIGHT = 90;
export const PADDLE_WIDTH = 12;
/** Vzdálenost pálky od kraje hřiště. */
export const PADDLE_MARGIN = 20;
export const BALL_SIZE = 12;
export const TICK_MS = 33;
/** Rychlosti v jednotkách hřiště za sekundu. */
export const PADDLE_SPEED = 420;
/** Rychlost pálky při ovládání myší — rychlé, ale ne okamžité přeskočení. */
export const MOUSE_PADDLE_SPEED = 1600;
export const BALL_START_SPEED = 330;
export const BALL_MAX_SPEED = 900;
/** Každý odpal míček zrychlí o tento násobek. */
export const BALL_SPEEDUP = 1.07;
/** Nejostřejší úhel odrazu od pálky (rad). */
const MAX_BOUNCE_ANGLE = Math.PI / 3.2;
export const WIN_SCORE = 5;
export const COUNTDOWN_SECONDS = 3;
/** Pauza po gólu, než se míček znovu rozehraje (ms). */
export const SERVE_PAUSE_MS = 1000;
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
export const WATCH_ROOM = 'pong-watchers';
export type PongPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
export type BotLevel = 'easy' | 'medium' | 'hard' | 'impossible';
type BotProfile = {
label: string,
/** Rychlost pálky bota. */
speed: number,
/** Jak často bot přehodnocuje, kam jet (ms) — simuluje reakční dobu. */
reactMs: number,
/** Max. odchylka míření (± jednotek hřiště). */
error: number,
/** Počítá, kam míček dopadne (včetně odrazů), nebo jen sleduje jeho výšku. */
predict: boolean,
};
export const BOT_PROFILES: Record<BotLevel, BotProfile> = {
easy: { label: 'Lehký', speed: 250, reactMs: 320, error: 70, predict: false },
medium: { label: 'Střední', speed: 340, reactMs: 200, error: 40, predict: true },
hard: { label: 'Těžký', speed: 450, reactMs: 110, error: 20, predict: true },
impossible: { label: 'Nemožný', speed: 900, reactMs: TICK_MS, error: 0, predict: true },
};
/** Jméno bota — s emoji, aby nekolidovalo s přihlašovacími jmény. */
export const botLogin = (level: BotLevel) => `🤖 Bot (${BOT_PROFILES[level].label})`;
export type PaddleInput = 'up' | 'down' | 'stop';
export type PongPlayer = {
login: string,
/** 0 = levá pálka, 1 = pravá. */
side: 0 | 1,
/** Svislá pozice středu pálky. */
y: number,
score: number,
/** Obtížnost, pokud jde o bota. */
bot?: BotLevel,
};
export type PongState = {
phase: PongPhase,
host?: string,
players: PongPlayer[],
ball: { x: number, y: number },
countdown?: number,
winner?: string,
width: number,
height: number,
};
type InternalPlayer = PongPlayer & {
input: PaddleInput,
/** Cílová výška při ovládání myší (přebíjí klávesy, dokud se nezmáčkne šipka). */
target?: number,
/** Kam bot míří a kdy se znovu rozhodne. */
botAim?: number,
botThinkAt?: number,
};
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
export class PongGame {
private phase: PongPhase = 'idle';
private host?: string;
private players: InternalPlayer[] = [];
private ball = { x: FIELD_WIDTH / 2, y: FIELD_HEIGHT / 2, vx: 0, vy: 0 };
private countdown = 0;
private winner?: string;
/** Míček čeká na rozehrání po gólu. */
private serveAt = 0;
private lastTick = 0;
private timer: unknown;
constructor(
private readonly broadcastAll: (state: PongState) => void,
private readonly broadcastWatchers: (state: PongState) => void,
private readonly clock: Clock = defaultClock,
) { }
getState(): PongState {
return {
phase: this.phase,
host: this.host,
players: this.players.map(p => ({ login: p.login, side: p.side, y: Math.round(p.y), score: p.score, bot: p.bot })),
ball: { x: Math.round(this.ball.x), y: Math.round(this.ball.y) },
countdown: this.phase === 'countdown' ? this.countdown : undefined,
winner: this.winner,
width: FIELD_WIDTH,
height: FIELD_HEIGHT,
};
}
join(login: string) {
if (this.phase === 'idle' || this.phase === 'finished') {
this.reset();
this.phase = 'lobby';
this.host = login;
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
} else if (this.phase !== 'lobby') {
throw new GameError('Hra už běží, počkejte na další.');
}
if (this.players.some(p => p.login === login)) return;
if (this.players.length >= 2) {
throw new GameError('Pong je jen pro dva hráče.');
}
this.players.push({ login, side: this.players.length as 0 | 1, y: FIELD_HEIGHT / 2, score: 0, input: 'stop' });
this.emitAll();
}
/** Zakladatel přidá do lobby bota místo druhého hráče. */
addBot(login: string, level: unknown) {
if (this.phase !== 'lobby') {
throw new GameError('Není otevřené žádné lobby.');
}
if (login !== this.host) {
throw new GameError('Bota může přidat jen ten, kdo lobby založil.');
}
if (typeof level !== 'string' || !(level in BOT_PROFILES)) {
throw new GameError('Neznámá obtížnost bota.');
}
if (this.players.length >= 2) {
throw new GameError('Pong je jen pro dva hráče.');
}
const bot = level as BotLevel;
this.players.push({ login: botLogin(bot), side: 1, y: FIELD_HEIGHT / 2, score: 0, input: 'stop', bot });
this.emitAll();
}
/** Zakladatel odebere bota z lobby. */
removeBot(login: string) {
if (this.phase !== 'lobby' || login !== this.host) return;
this.players = this.players.filter(p => !p.bot);
this.emitAll();
}
leave(login: string) {
if (this.phase !== 'lobby' || !this.players.some(p => p.login === login)) return;
if (login === this.host) {
this.cancel();
return;
}
this.players = this.players.filter(p => p.login !== login);
this.emitAll();
}
start(login: string) {
if (this.phase !== 'lobby') {
throw new GameError('Není otevřené žádné lobby.');
}
if (login !== this.host) {
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
}
if (this.players.length < 2) {
throw new GameError('Na Pong jsou potřeba dva hráči.');
}
this.players.forEach(p => {
p.y = FIELD_HEIGHT / 2;
p.score = 0;
p.input = 'stop';
p.target = undefined;
p.botAim = undefined;
p.botThinkAt = undefined;
});
this.phase = 'countdown';
this.countdown = COUNTDOWN_SECONDS;
this.emitAll();
this.schedule(() => this.countdownStep(), 1000);
}
input(login: string, input: unknown) {
const p = this.players.find(x => x.login === login);
if (!p || p.bot || (input !== 'up' && input !== 'down' && input !== 'stop')) return;
p.input = input;
// Šipka přebíjí myš; puštění klávesy myš neruší
if (input !== 'stop') p.target = undefined;
}
/** Ovládání myší: pálka jede rychle k zadané výšce. */
aim(login: string, y: unknown) {
const p = this.players.find(x => x.login === login);
if (!p || p.bot || typeof y !== 'number' || !Number.isFinite(y)) return;
p.target = clamp(y, 0, FIELD_HEIGHT);
p.input = 'stop';
}
private countdownStep() {
this.countdown--;
if (this.countdown > 0) {
this.emitAll();
this.schedule(() => this.countdownStep(), 1000);
return;
}
this.phase = 'playing';
this.serve(Math.round(this.clock.random()) as 0 | 1);
this.lastTick = this.clock.now();
this.emitAll();
this.schedule(() => this.step(), TICK_MS);
}
/** Míček do středu, po pauze letí k dané straně pod náhodným úhlem. */
private serve(towards: 0 | 1) {
const angle = (this.clock.random() - 0.5) * (Math.PI / 3);
const dir = towards === 0 ? -1 : 1;
this.ball = {
x: FIELD_WIDTH / 2,
y: FIELD_HEIGHT / 2,
vx: dir * BALL_START_SPEED * Math.cos(angle),
vy: BALL_START_SPEED * Math.sin(angle),
};
this.serveAt = this.clock.now() + SERVE_PAUSE_MS;
}
private step() {
const now = this.clock.now();
// Skutečně uplynulý čas, ať zpožděný tick nezpomalí hru (max. 100 ms proti skokům)
const dt = Math.min(0.1, (now - this.lastTick) / 1000);
this.lastTick = now;
for (const p of this.players) {
if (p.bot) {
this.moveBot(p, now, dt);
} else if (p.target !== undefined) {
p.y = this.approach(p.y, p.target, MOUSE_PADDLE_SPEED * dt);
} else {
const dir = p.input === 'up' ? -1 : p.input === 'down' ? 1 : 0;
p.y = clamp(p.y + dir * PADDLE_SPEED * dt, PADDLE_HEIGHT / 2, FIELD_HEIGHT - PADDLE_HEIGHT / 2);
}
}
if (now >= this.serveAt) {
this.moveBall(dt);
}
if (this.phase !== 'playing') return;
this.emitWatchers();
this.schedule(() => this.step(), TICK_MS);
}
/** Posune pálku k cíli nejvýš o `maxStep`, v mezích hřiště. */
private approach(y: number, target: number, maxStep: number) {
const goal = clamp(target, PADDLE_HEIGHT / 2, FIELD_HEIGHT - PADDLE_HEIGHT / 2);
return Math.abs(goal - y) <= maxStep ? goal : y + Math.sign(goal - y) * maxStep;
}
/** Bot se v intervalu reakční doby rozhodne, kam jet, a jede tam svou rychlostí. */
private moveBot(p: InternalPlayer, now: number, dt: number) {
const profile = BOT_PROFILES[p.bot!];
if (p.botThinkAt === undefined || now >= p.botThinkAt) {
p.botThinkAt = now + profile.reactMs;
const b = this.ball;
const incoming = p.side === 0 ? b.vx < 0 : b.vx > 0;
let aim: number;
if (!incoming || now < this.serveAt) {
// Míček letí pryč — vrátit se doprostřed
aim = FIELD_HEIGHT / 2;
} else if (profile.predict) {
aim = this.predictY(p.side);
} else {
aim = b.y;
}
p.botAim = aim + (this.clock.random() * 2 - 1) * profile.error;
}
p.y = this.approach(p.y, p.botAim ?? FIELD_HEIGHT / 2, profile.speed * dt);
}
/** Výška, ve které míček dorazí k pálce dané strany (s odrazy od horní a dolní stěny). */
private predictY(side: 0 | 1): number {
const b = this.ball;
const r = BALL_SIZE / 2;
const faceX = side === 0 ? PADDLE_MARGIN + PADDLE_WIDTH + r : FIELD_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH - r;
const t = (faceX - b.x) / b.vx;
if (!Number.isFinite(t) || t < 0) return b.y;
// Odrazy "rozbalíme": pohyb v pásmu [r, H - r] je periodický s periodou 2 × výška pásma
const span = FIELD_HEIGHT - 2 * r;
const period = 2 * span;
let y = ((b.y + b.vy * t - r) % period + period) % period;
if (y > span) y = period - y;
return y + r;
}
private moveBall(dt: number) {
const b = this.ball;
b.x += b.vx * dt;
b.y += b.vy * dt;
const r = BALL_SIZE / 2;
if (b.y - r < 0) { b.y = r; b.vy = Math.abs(b.vy); }
if (b.y + r > FIELD_HEIGHT) { b.y = FIELD_HEIGHT - r; b.vy = -Math.abs(b.vy); }
for (const p of this.players) {
const faceX = p.side === 0 ? PADDLE_MARGIN + PADDLE_WIDTH : FIELD_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH;
const movingTowards = p.side === 0 ? b.vx < 0 : b.vx > 0;
const reached = p.side === 0 ? b.x - r <= faceX : b.x + r >= faceX;
const behind = p.side === 0 ? b.x + r < PADDLE_MARGIN : b.x - r > FIELD_WIDTH - PADDLE_MARGIN;
if (!movingTowards || !reached || behind) continue;
const offset = (b.y - p.y) / (PADDLE_HEIGHT / 2 + r);
if (Math.abs(offset) > 1) continue;
// Úhel odrazu podle místa zásahu na pálce, rychlost postupně roste
const speed = Math.min(BALL_MAX_SPEED, Math.hypot(b.vx, b.vy) * BALL_SPEEDUP);
const angle = offset * MAX_BOUNCE_ANGLE;
b.vx = (p.side === 0 ? 1 : -1) * speed * Math.cos(angle);
b.vy = speed * Math.sin(angle);
b.x = p.side === 0 ? faceX + r : faceX - r;
}
if (b.x < 0) this.point(1);
else if (b.x > FIELD_WIDTH) this.point(0);
}
private point(side: 0 | 1) {
const scorer = this.players.find(p => p.side === side);
if (!scorer) return;
scorer.score++;
if (scorer.score >= WIN_SCORE) {
this.winner = scorer.login;
this.phase = 'finished';
this.clearTimer();
this.emitAll();
return;
}
// Podává se k tomu, kdo bod ztratil
this.serve(side === 0 ? 1 : 0);
this.emitAll();
}
private cancel() {
this.reset();
this.emitAll();
}
private reset() {
this.clearTimer();
this.phase = 'idle';
this.host = undefined;
this.players = [];
this.winner = undefined;
this.ball = { x: FIELD_WIDTH / 2, y: FIELD_HEIGHT / 2, vx: 0, vy: 0 };
}
private schedule(fn: () => void, ms: number) {
this.clearTimer();
this.timer = this.clock.setTimeout(() => {
this.timer = undefined;
fn();
}, ms);
}
private clearTimer() {
if (this.timer !== undefined) {
this.clock.clearTimeout(this.timer);
this.timer = undefined;
}
}
private emitAll() {
this.broadcastAll(this.getState());
}
private emitWatchers() {
this.broadcastWatchers(this.getState());
}
/** Pro testy: nastaví míček na konkrétní pozici a rychlost. */
setBallForTest(x: number, y: number, vx: number, vy: number) {
this.ball = { x, y, vx, vy };
this.serveAt = 0;
}
}
/** Zaregistruje websocket handlery Pongu. */
export function registerPongGame(io: Server) {
const game = new PongGame(
state => io.emit('pong:state', state),
state => io.to(WATCH_ROOM).emit('pong:state', state),
);
io.on('connection', socket => {
const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'pong:error', action);
socket.on('pong:sync', () => socket.emit('pong:state', game.getState()));
socket.on('pong:watch', () => {
socket.join(WATCH_ROOM);
socket.emit('pong:state', game.getState());
});
socket.on('pong:unwatch', () => socket.leave(WATCH_ROOM));
socket.on('pong:join', handle(login => game.join(login)));
socket.on('pong:leave', handle(login => game.leave(login)));
socket.on('pong:start', handle(login => game.start(login)));
socket.on('pong:input', handle((login, payload) => game.input(login, (payload as { input?: unknown })?.input)));
socket.on('pong:aim', handle((login, payload) => game.aim(login, (payload as { y?: unknown })?.y)));
socket.on('pong:addBot', handle((login, payload) => game.addBot(login, (payload as { level?: unknown })?.level)));
socket.on('pong:removeBot', handle(login => game.removeBot(login)));
});
}
+250
View File
@@ -0,0 +1,250 @@
import { Server } from "socket.io";
import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } from "./minigames";
/**
* Reakční hra — krátká multiplayerová hra přes websocket.
*
* Hráči se přidají do lobby, pak proběhnou {@link ROUNDS} kola: server po náhodné
* prodlevě vyhlásí "TEĎ!" a měří čas od vyhlášení do přijetí kliknutí. Kdo klikne
* před vyhlášením (předčasný start) nebo neklikne vůbec, dostane trestný čas.
* Pořadí určuje součet časů — nejnižší vyhrává.
*
* Veškeré časování drží server, klient jen posílá kliknutí. Stav je v paměti procesu,
* takže hra předpokládá jednu repliku serveru (broadcast přes Redis adapter funguje,
* ale akce z jiné repliky by stav neviděly).
*/
export const ROUNDS = 3;
/** Trestný čas za předčasný start nebo nekliknutí (ms). */
export const PENALTY_MS = 2000;
/** Jak dlouho po vyhlášení se čeká na kliknutí (ms). */
export const CLICK_WINDOW_MS = 2000;
export const MIN_DELAY_MS = 2000;
export const MAX_DELAY_MS = 6000;
/** Pauza mezi koly pro zobrazení výsledku kola (ms). */
export const ROUND_PAUSE_MS = 2500;
/** Neodstartované lobby se po této době zruší (ms). */
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
export const MAX_PLAYERS = 12;
export type DuelPhase = 'idle' | 'lobby' | 'waiting' | 'go' | 'roundResult' | 'finished';
export type RoundResult = {
/** Reakční čas v ms, nebo null při předčasném startu / nekliknutí. */
ms: number | null,
falseStart?: boolean,
};
export type DuelPlayer = {
login: string,
/** Výsledky jednotlivých kol. */
rounds: RoundResult[],
/** Součet časů včetně trestů. */
total: number,
};
export type DuelState = {
phase: DuelPhase,
host?: string,
round: number,
totalRounds: number,
players: DuelPlayer[],
/** Login posledního hráče (jen ve fázi finished). */
loser?: string,
/** Login nejrychlejšího hráče celé hry (jen ve fázi finished). */
winner?: string,
minPlayers: number,
};
export class ReactionDuel {
private phase: DuelPhase = 'idle';
private host?: string;
private round = 0;
private players = new Map<string, DuelPlayer>();
/** Výsledky aktuálního kola. */
private current = new Map<string, RoundResult>();
private goAt = 0;
private timer: unknown;
private loser?: string;
private winner?: string;
constructor(
private readonly broadcast: (state: DuelState) => void,
private readonly minPlayers = 2,
private readonly clock: Clock = defaultClock,
) { }
getState(): DuelState {
return {
phase: this.phase,
host: this.host,
round: this.round,
totalRounds: ROUNDS,
players: [...this.players.values()].map(p => ({ ...p, rounds: [...p.rounds] })),
loser: this.loser,
winner: this.winner,
minPlayers: this.minPlayers,
};
}
/** Přidá hráče; pokud žádná hra neběží, založí nové lobby. */
join(login: string) {
if (this.phase === 'idle' || this.phase === 'finished') {
this.reset();
this.phase = 'lobby';
this.host = login;
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
} else if (this.phase !== 'lobby') {
throw new GameError('Hra už běží, počkejte na další.');
}
if (!this.players.has(login)) {
if (this.players.size >= MAX_PLAYERS) {
throw new GameError('Lobby je plné.');
}
this.players.set(login, { login, rounds: [], total: 0 });
}
this.emit();
}
/** Odebere hráče z lobby; odchodem zakladatele se lobby ruší. */
leave(login: string) {
if (this.phase !== 'lobby' || !this.players.has(login)) {
return;
}
this.players.delete(login);
if (login === this.host || this.players.size === 0) {
this.cancel();
return;
}
this.emit();
}
/** Spustí hru, smí jen zakladatel lobby. */
start(login: string) {
if (this.phase !== 'lobby') {
throw new GameError('Není otevřené žádné lobby.');
}
if (login !== this.host) {
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
}
if (this.players.size < this.minPlayers) {
throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
}
this.nextRound();
}
/** Zpracuje kliknutí hráče. */
click(login: string) {
if (!this.players.has(login) || this.current.has(login)) {
return;
}
if (this.phase === 'waiting') {
this.current.set(login, { ms: null, falseStart: true });
} else if (this.phase === 'go') {
this.current.set(login, { ms: Math.max(0, this.clock.now() - this.goAt) });
} else {
return;
}
if (this.current.size === this.players.size && this.phase === 'go') {
this.endRound();
} else {
this.emit();
}
}
private nextRound() {
this.round++;
this.current.clear();
this.phase = 'waiting';
const delay = MIN_DELAY_MS + Math.floor(this.clock.random() * (MAX_DELAY_MS - MIN_DELAY_MS));
this.schedule(() => this.go(), delay);
this.emit();
}
private go() {
this.phase = 'go';
this.goAt = this.clock.now();
if (this.current.size === this.players.size) {
// Všichni odstartovali předčasně, není na co čekat
this.endRound();
return;
}
this.schedule(() => this.endRound(), CLICK_WINDOW_MS);
this.emit();
}
private endRound() {
for (const player of this.players.values()) {
const result = this.current.get(player.login) ?? { ms: null };
player.rounds.push(result);
player.total += result.ms ?? PENALTY_MS;
}
this.current.clear();
if (this.round >= ROUNDS) {
this.finish();
return;
}
this.phase = 'roundResult';
this.schedule(() => this.nextRound(), ROUND_PAUSE_MS);
this.emit();
}
private finish() {
const ranked = [...this.players.values()].sort((a, b) => a.total - b.total);
this.winner = ranked[0]?.login;
this.loser = ranked.length > 1 ? ranked[ranked.length - 1].login : undefined;
this.phase = 'finished';
this.clearTimer();
this.emit();
}
private cancel() {
this.reset();
this.emit();
}
private reset() {
this.clearTimer();
this.phase = 'idle';
this.host = undefined;
this.round = 0;
this.players.clear();
this.current.clear();
this.loser = undefined;
this.winner = undefined;
}
private schedule(fn: () => void, ms: number) {
this.clearTimer();
this.timer = this.clock.setTimeout(() => {
this.timer = undefined;
fn();
}, ms);
}
private clearTimer() {
if (this.timer !== undefined) {
this.clock.clearTimeout(this.timer);
this.timer = undefined;
}
}
/** Rozešle stav všem klientům; výsledky kola se zveřejní až po jeho skončení. */
private emit() {
this.broadcast(this.getState());
}
}
/** Zaregistruje websocket handlery reakčního duelu. */
export function registerReactionDuel(io: Server) {
const duel = new ReactionDuel(state => io.emit('duel:state', state), minPlayersForEnv(2));
io.on('connection', socket => {
const handle = (action: (login: string) => void) => gameHandler(socket, 'duel:error', action);
socket.on('duel:sync', () => socket.emit('duel:state', duel.getState()));
socket.on('duel:join', handle(login => duel.join(login)));
socket.on('duel:leave', handle(login => duel.leave(login)));
socket.on('duel:start', handle(login => duel.start(login)));
socket.on('duel:click', handle(login => duel.click(login)));
});
}
+344
View File
@@ -0,0 +1,344 @@
import { Server } from "socket.io";
import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
/**
* Multiplayerový had přes websocket.
*
* Server drží celou simulaci (tick smyčka, kolize, jídlo), klienti posílají jen směr.
* Had umírá nárazem do zdi, do sebe nebo do jiného hada (čelní srážka zabije oba).
* Pořadí: kdo přežije déle, je výš; při stejném okamžiku smrti rozhoduje délka.
* Hra končí, když zbyde poslední živý had (sólo hra až smrtí), nebo po {@link MAX_TICKS}.
*
* Průběžné snímky chodí jen do místnosti {@link WATCH_ROOM} (klienti s otevřeným oknem),
* ostatním jen změny lobby kvůli pozvánce. Stav je v paměti, předpokládá jednu repliku.
*/
export const WIDTH = 32;
export const HEIGHT = 20;
export const TICK_MS = 120;
/** Maximální délka hry (~3 minuty). */
export const MAX_TICKS = Math.round(3 * 60 * 1000 / TICK_MS);
export const COUNTDOWN_SECONDS = 3;
export const START_LENGTH = 3;
export const MAX_PLAYERS = 8;
/** Neodstartované lobby se po této době zruší (ms). */
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
/** Kolik změn směru si server pamatuje dopředu (rychlé zatáčky v jednom ticku). */
const MAX_QUEUED_TURNS = 2;
export const WATCH_ROOM = 'snake-watchers';
export type Direction = 'up' | 'down' | 'left' | 'right';
export type Cell = [number, number];
export type SnakePhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
export type SnakePlayer = {
login: string,
/** Index barvy hada (klient si mapuje na paletu). */
color: number,
/** Tělo od hlavy po ocas. */
body: Cell[],
alive: boolean,
/** Počet snědených jídel. */
score: number,
/** Konečné umístění (jen ve fázi finished). */
place?: number,
};
export type SnakeState = {
phase: SnakePhase,
host?: string,
players: SnakePlayer[],
food: Cell[],
tick: number,
maxTicks: number,
/** Zbývající sekundy odpočtu (ve fázi countdown). */
countdown?: number,
width: number,
height: number,
minPlayers: number,
};
type InternalPlayer = SnakePlayer & {
dir: Direction,
queue: Direction[],
deathTick?: number,
};
const DELTAS: Record<Direction, Cell> = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
const OPPOSITE: Record<Direction, Direction> = { up: 'down', down: 'up', left: 'right', right: 'left' };
const isDirection = (d: unknown): d is Direction => typeof d === 'string' && d in DELTAS;
const key = ([x, y]: Cell) => `${x},${y}`;
export class SnakeGame {
private phase: SnakePhase = 'idle';
private host?: string;
private players = new Map<string, InternalPlayer>();
private food: Cell[] = [];
private tickNo = 0;
private countdown = 0;
private timer: unknown;
/**
* @param broadcastAll stav pro všechny (změny lobby a fáze)
* @param broadcastWatchers průběžné snímky jen pro klienty se otevřenou hrou
*/
constructor(
private readonly broadcastAll: (state: SnakeState) => void,
private readonly broadcastWatchers: (state: SnakeState) => void,
private readonly minPlayers = 1,
private readonly clock: Clock = defaultClock,
) { }
getState(): SnakeState {
return {
phase: this.phase,
host: this.host,
players: [...this.players.values()].map(p => ({
login: p.login,
color: p.color,
body: p.body.map(c => [c[0], c[1]] as Cell),
alive: p.alive,
score: p.score,
place: p.place,
})),
food: this.food.map(c => [c[0], c[1]] as Cell),
tick: this.tickNo,
maxTicks: MAX_TICKS,
countdown: this.phase === 'countdown' ? this.countdown : undefined,
width: WIDTH,
height: HEIGHT,
minPlayers: this.minPlayers,
};
}
join(login: string) {
if (this.phase === 'idle' || this.phase === 'finished') {
this.reset();
this.phase = 'lobby';
this.host = login;
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
} else if (this.phase !== 'lobby') {
throw new GameError('Hra už běží, počkejte na další.');
}
if (!this.players.has(login)) {
if (this.players.size >= MAX_PLAYERS) {
throw new GameError('Lobby je plné.');
}
const used = new Set([...this.players.values()].map(p => p.color));
let color = 0;
while (used.has(color)) color++;
this.players.set(login, { login, color, body: [], alive: true, score: 0, dir: 'right', queue: [] });
}
this.emitAll();
}
leave(login: string) {
if (this.phase !== 'lobby' || !this.players.has(login)) {
return;
}
this.players.delete(login);
if (login === this.host || this.players.size === 0) {
this.cancel();
return;
}
this.emitAll();
}
start(login: string) {
if (this.phase !== 'lobby') {
throw new GameError('Není otevřené žádné lobby.');
}
if (login !== this.host) {
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
}
if (this.players.size < this.minPlayers) {
throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
}
this.spawn();
this.phase = 'countdown';
this.countdown = COUNTDOWN_SECONDS;
this.emitAll();
this.schedule(() => this.countdownStep(), 1000);
}
/** Zařadí změnu směru; obrat o 180° a opakování stejného směru se ignorují. */
turn(login: string, dir: unknown) {
const p = this.players.get(login);
if (!p || !p.alive || !isDirection(dir) || (this.phase !== 'playing' && this.phase !== 'countdown')) {
return;
}
const last = p.queue.length ? p.queue[p.queue.length - 1] : p.dir;
if (dir === last || dir === OPPOSITE[last] || p.queue.length >= MAX_QUEUED_TURNS) {
return;
}
p.queue.push(dir);
}
/** Rozmístí hady do řádků, střídavě zleva doprava a zprava doleva. */
private spawn() {
const list = [...this.players.values()];
list.forEach((p, i) => {
const y = Math.floor((i + 1) * HEIGHT / (list.length + 1));
const fromLeft = i % 2 === 0;
const headX = fromLeft ? 2 + START_LENGTH : WIDTH - 3 - START_LENGTH;
p.dir = fromLeft ? 'right' : 'left';
p.body = Array.from({ length: START_LENGTH }, (_, k) => [fromLeft ? headX - k : headX + k, y] as Cell);
p.alive = true;
p.score = 0;
p.queue = [];
p.place = undefined;
p.deathTick = undefined;
});
this.food = [];
this.tickNo = 0;
this.refillFood();
}
private countdownStep() {
this.countdown--;
if (this.countdown > 0) {
this.emitAll();
this.schedule(() => this.countdownStep(), 1000);
return;
}
this.phase = 'playing';
this.emitAll();
this.schedule(() => this.step(), TICK_MS);
}
/** Jeden krok simulace. */
private step() {
this.tickNo++;
const alive = [...this.players.values()].filter(p => p.alive);
const foodKeys = new Set(this.food.map(key));
const eaten = new Set<string>();
// Pohyb: nová hlava, ocas se odebere, pokud had nejí
for (const p of alive) {
const next = p.queue.shift();
if (next) p.dir = next;
const [dx, dy] = DELTAS[p.dir];
const head: Cell = [p.body[0][0] + dx, p.body[0][1] + dy];
p.body.unshift(head);
if (foodKeys.has(key(head))) {
eaten.add(key(head));
p.score++;
} else {
p.body.pop();
}
}
// Kolize se vyhodnotí až po pohybu všech, aby nezáleželo na pořadí hráčů
const occupied = new Map<string, number>();
for (const p of alive) {
for (const c of p.body) occupied.set(key(c), (occupied.get(key(c)) ?? 0) + 1);
}
for (const p of alive) {
const [x, y] = p.body[0];
const outside = x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT;
if (outside || (occupied.get(key(p.body[0])) ?? 0) > 1) {
p.alive = false;
p.deathTick = this.tickNo;
}
}
for (const p of alive) {
if (!p.alive) p.body = [];
}
this.food = this.food.filter(c => !eaten.has(key(c)));
this.refillFood();
const stillAlive = alive.filter(p => p.alive).length;
const multiplayer = this.players.size > 1;
if (stillAlive === 0 || (multiplayer && stillAlive <= 1) || this.tickNo >= MAX_TICKS) {
this.finish();
return;
}
this.emitWatchers();
this.schedule(() => this.step(), TICK_MS);
}
/** Na hrací ploše je tolik jídel, kolik je hráčů. */
private refillFood() {
const taken = new Set<string>(this.food.map(key));
for (const p of this.players.values()) p.body.forEach(c => taken.add(key(c)));
const target = Math.max(1, this.players.size);
let attempts = 0;
while (this.food.length < target && attempts++ < 1000) {
const cell: Cell = [Math.floor(this.clock.random() * WIDTH), Math.floor(this.clock.random() * HEIGHT)];
if (!taken.has(key(cell))) {
taken.add(key(cell));
this.food.push(cell);
}
}
}
private finish() {
const ranked = [...this.players.values()].sort((a, b) =>
(b.deathTick ?? Infinity) - (a.deathTick ?? Infinity) || b.score - a.score);
ranked.forEach((p, i) => { p.place = i + 1; });
this.phase = 'finished';
this.clearTimer();
this.emitAll();
}
private cancel() {
this.reset();
this.emitAll();
}
private reset() {
this.clearTimer();
this.phase = 'idle';
this.host = undefined;
this.players.clear();
this.food = [];
this.tickNo = 0;
}
private schedule(fn: () => void, ms: number) {
this.clearTimer();
this.timer = this.clock.setTimeout(() => {
this.timer = undefined;
fn();
}, ms);
}
private clearTimer() {
if (this.timer !== undefined) {
this.clock.clearTimeout(this.timer);
this.timer = undefined;
}
}
private emitAll() {
this.broadcastAll(this.getState());
}
private emitWatchers() {
this.broadcastWatchers(this.getState());
}
}
/** Zaregistruje websocket handlery hada. */
export function registerSnakeGame(io: Server) {
const game = new SnakeGame(
state => io.emit('snake:state', state),
state => io.to(WATCH_ROOM).emit('snake:state', state),
1, // had se dá hrát i sólo
);
io.on('connection', socket => {
const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'snake:error', action);
socket.on('snake:sync', () => socket.emit('snake:state', game.getState()));
socket.on('snake:watch', () => {
socket.join(WATCH_ROOM);
socket.emit('snake:state', game.getState());
});
socket.on('snake:unwatch', () => socket.leave(WATCH_ROOM));
socket.on('snake:join', handle(login => game.join(login)));
socket.on('snake:leave', handle(login => game.leave(login)));
socket.on('snake:start', handle(login => game.start(login)));
socket.on('snake:turn', handle((login, payload) => game.turn(login, (payload as { dir?: unknown })?.dir)));
});
}
+137
View File
@@ -0,0 +1,137 @@
import { DrawingGame, DrawState, Stroke, toWord, isCorrectGuess, ROUND_MS, REVEAL_MS, GUESS_MAX_POINTS, DRAWER_POINTS_PER_GUESS, FALLBACK_WORDS } from '../drawingGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
describe('kreslení — slova z menu', () => {
test.each([
['Plzeňský hovězí guláš s kynutým knedlíkem (1, 3, 7)', 'Plzeňský hovězí guláš', ['gulas']],
['Tortilla wrap s trhaným vepřovým masem, slaninou a čedarem', 'Tortilla wrap', ['tortilla', 'wrap']],
['Čevabčiči, brambory, cibule, hořčice (1, 3, 7)', 'Čevabčiči', ['cevabcici']],
['Sýrová pizza, čerstvá bazalka (1, 7)', 'Sýrová pizza', ['pizza']],
['Medailonky z panenky se slaninovým kroupotem', 'Medailonky z panenky', ['medailonky', 'panenky']],
])('%s', (dish, display, keywords) => {
expect(toWord(dish)).toEqual({ display, keywords });
});
test('hádání toleruje diakritiku, velikost písmen a koncovky', () => {
const gulas = toWord('Plzeňský hovězí guláš');
expect(isCorrectGuess('gulas', gulas)).toBe(true);
expect(isCorrectGuess(' GULÁŠ ', gulas)).toBe(true);
expect(isCorrectGuess('je to guláš?', gulas)).toBe(true);
expect(isCorrectGuess('hovězí', gulas)).toBe(false);
expect(isCorrectGuess('gul', gulas)).toBe(false);
expect(isCorrectGuess('knedliky', toWord('Knedlík'))).toBe(true);
expect(isCorrectGuess('knedliky', toWord('Bramborový knedlík'))).toBe(true);
expect(isCorrectGuess('pizzu', toWord('Sýrová pizza'))).toBe(true);
});
});
function setup(dishes: string[] = ['Plzeňský hovězí guláš', 'Sýrová pizza', 'Smažený řízek']) {
const clock = fakeClock();
const states: DrawState[] = [];
const words: { login: string, word: string }[] = [];
const strokes: Stroke[] = [];
let clears = 0;
const game = new DrawingGame(
s => states.push(s),
(login, word) => words.push({ login, word }),
s => strokes.push(s),
() => { clears++; },
async () => dishes,
2,
clock,
);
const last = () => states[states.length - 1];
const player = (login: string) => last().players.find(p => p.login === login)!;
const startGame = async (...logins: string[]) => {
logins.forEach(l => game.join(l));
await game.start(logins[0]);
};
return { game, clock, states, words, strokes, last, player, startGame, clears: () => clears };
}
describe('kreslení — průběh hry', () => {
test('slovo dostane jen kreslíř, ostatní vidí masku', async () => {
const ctx = setup();
await ctx.startGame('alice', 'bob');
const state = ctx.last();
expect(state).toMatchObject({ phase: 'drawing', drawer: 'alice', round: 1, totalRounds: 2 });
expect(ctx.words).toHaveLength(1);
expect(ctx.words[0].login).toBe('alice');
expect(JSON.stringify(ctx.states)).not.toContain(ctx.words[0].word);
expect(state.hint).toMatch(/^[_ ]+$/);
expect(ctx.game.wordFor('bob')).toBeUndefined();
});
test('správný tip dá body hádajícímu i kreslíři a ukončí kolo, když uhodli všichni', async () => {
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
await ctx.startGame('alice', 'bob');
ctx.game.guess('bob', 'svíčková');
expect(ctx.last().messages.at(-1)).toEqual({ login: 'bob', text: 'svíčková' });
ctx.game.guess('bob', 'guláš');
expect(ctx.player('bob').score).toBe(GUESS_MAX_POINTS);
expect(ctx.player('alice').score).toBe(DRAWER_POINTS_PER_GUESS);
expect(ctx.last()).toMatchObject({ phase: 'reveal', answer: 'Plzeňský hovězí guláš' });
ctx.clock.advance(REVEAL_MS);
expect(ctx.last()).toMatchObject({ phase: 'drawing', drawer: 'bob', round: 2 });
});
test('kreslíř a ti, kdo už uhodli, nemohou psát do chatu', async () => {
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
await ctx.startGame('alice', 'bob', 'carol');
const before = ctx.last().messages.length;
ctx.game.guess('alice', 'je to guláš');
ctx.game.guess('bob', 'guláš');
ctx.game.guess('bob', 'guláš s knedlíkem!');
const texts = ctx.last().messages.slice(before).map(m => m.text);
expect(texts).toEqual(['bob uhodl(a)! 🎉']);
});
test('pozdější tip má méně bodů a nápověda postupně odkrývá písmena', async () => {
const ctx = setup(['Plzeňský hovězí guláš', 'Plzeňský hovězí guláš']);
await ctx.startGame('alice', 'bob', 'carol');
ctx.clock.advance(ROUND_MS * 0.75);
expect(ctx.last().hint!.replace(/[_ ]/g, '')).toHaveLength(2);
ctx.game.guess('bob', 'gulas');
expect(ctx.player('bob').score).toBe(25);
});
test('po vypršení času kolo skončí a po všech kolech hra', async () => {
const ctx = setup();
await ctx.startGame('alice', 'bob');
ctx.clock.advance(ROUND_MS);
expect(ctx.last().phase).toBe('reveal');
ctx.clock.advance(REVEAL_MS + ROUND_MS + REVEAL_MS);
expect(ctx.last().phase).toBe('finished');
expect(ctx.last().players.every(p => p.place !== undefined)).toBe(true);
});
test('kreslit smí jen kreslíř a neplatné tahy se zahodí', async () => {
const ctx = setup();
await ctx.startGame('alice', 'bob');
const stroke = { color: '#ff0000', size: 4, points: [[10, 10], [20, 20]], start: true };
ctx.game.stroke('bob', stroke);
ctx.game.stroke('alice', { ...stroke, color: 'red' });
ctx.game.stroke('alice', { ...stroke, points: [[5000, 1]] });
expect(ctx.strokes).toHaveLength(0);
ctx.game.stroke('alice', stroke);
expect(ctx.strokes).toHaveLength(1);
expect(ctx.game.getStrokes()).toHaveLength(1);
ctx.game.clear('alice');
expect(ctx.game.getStrokes()).toHaveLength(0);
});
test('bez menu se hraje se záložními slovy', async () => {
const ctx = setup([]);
await ctx.startGame('alice', 'bob');
expect(FALLBACK_WORDS.map(w => toWord(w).display)).toContain(ctx.words[0].word);
});
test('hru spustí jen zakladatel s dostatkem hráčů', async () => {
const ctx = setup();
ctx.game.join('alice');
await expect(ctx.game.start('alice')).rejects.toThrow(GameError);
ctx.game.join('bob');
await expect(ctx.game.start('bob')).rejects.toThrow(GameError);
});
});
+27
View File
@@ -0,0 +1,27 @@
/** Ruční hodiny — časovače se spouští jen přes advance(). */
export function fakeClock(random: () => number = () => 0) {
let now = 1_000_000;
let timers: { at: number, fn: () => void, id: number }[] = [];
let nextId = 1;
return {
now: () => now,
random,
setTimeout: (fn: () => void, ms: number) => {
const id = nextId++;
timers.push({ at: now + ms, fn, id });
return id;
},
clearTimeout: (id: unknown) => { timers = timers.filter(t => t.id !== id); },
advance(ms: number) {
const target = now + ms;
for (;;) {
const due = timers.filter(t => t.at <= target).sort((a, b) => a.at - b.at)[0];
if (!due) break;
timers = timers.filter(t => t !== due);
now = due.at;
due.fn();
}
now = target;
},
};
}
+124
View File
@@ -0,0 +1,124 @@
import { PongGame, PongState, TICK_MS, COUNTDOWN_SECONDS, FIELD_HEIGHT, PADDLE_MARGIN, PADDLE_WIDTH, PADDLE_HEIGHT, WIN_SCORE, SERVE_PAUSE_MS, botLogin, BotLevel } from '../pongGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
/** Deterministická pseudonáhoda, ať se boti nechovají pokaždé stejně jako s konstantou. */
function lcg(seed = 1) {
let x = seed;
return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648;
}
function setup(random?: () => number) {
const clock = fakeClock(random);
const states: PongState[] = [];
const game = new PongGame(s => states.push(s), s => states.push(s), clock);
const last = () => states[states.length - 1];
const player = (login: string) => last().players.find(p => p.login === login)!;
const startGame = () => {
game.join('alice');
game.join('bob');
game.start('alice');
clock.advance(COUNTDOWN_SECONDS * 1000);
};
const ticks = (n: number) => clock.advance(n * TICK_MS);
return { game, clock, last, player, startGame, ticks };
}
describe('pong', () => {
test('je jen pro dva hráče a hru spouští zakladatel', () => {
const ctx = setup();
ctx.game.join('alice');
expect(() => ctx.game.start('alice')).toThrow(GameError);
ctx.game.join('bob');
expect(() => ctx.game.join('carol')).toThrow(GameError);
expect(() => ctx.game.start('bob')).toThrow(GameError);
ctx.game.start('alice');
expect(ctx.last().phase).toBe('countdown');
});
test('pálka se hýbe podle vstupu a nevyjede z hřiště', () => {
const ctx = setup();
ctx.startGame();
ctx.game.input('alice', 'up');
ctx.ticks(10);
expect(ctx.player('alice').y).toBeLessThan(FIELD_HEIGHT / 2);
ctx.ticks(100);
expect(ctx.player('alice').y).toBe(45);
ctx.game.input('alice', 'stop');
const y = ctx.player('alice').y;
ctx.ticks(5);
expect(ctx.player('alice').y).toBe(y);
});
test('míček se od pálky odrazí', () => {
const ctx = setup();
ctx.startGame();
const face = PADDLE_MARGIN + PADDLE_WIDTH;
ctx.game.setBallForTest(face + 20, FIELD_HEIGHT / 2, -400, 0);
ctx.ticks(3);
expect(ctx.last().ball.x).toBeGreaterThan(face);
expect(ctx.player('bob').score).toBe(0);
});
test('minutý míček je bod pro soupeře a hra končí na vítězné skóre', () => {
const ctx = setup();
ctx.startGame();
for (let i = 0; i < WIN_SCORE; i++) {
// Míček u horního okraje, kam pálka uprostřed nedosáhne
ctx.game.setBallForTest(60, 10, -600, 0);
ctx.ticks(10);
if (i < WIN_SCORE - 1) {
expect(ctx.player('bob').score).toBe(i + 1);
ctx.clock.advance(SERVE_PAUSE_MS);
}
}
expect(ctx.last()).toMatchObject({ phase: 'finished', winner: 'bob' });
});
test('myš: pálka rychle dojede k cílové výšce a šipka myš přebije', () => {
const ctx = setup();
ctx.startGame();
ctx.game.aim('alice', 0);
ctx.ticks(5);
expect(ctx.player('alice').y).toBe(PADDLE_HEIGHT / 2);
ctx.game.input('alice', 'down');
ctx.ticks(5);
expect(ctx.player('alice').y).toBeGreaterThan(PADDLE_HEIGHT / 2);
});
test('bota přidá jen zakladatel a lze ho odebrat', () => {
const ctx = setup();
ctx.game.join('alice');
expect(() => ctx.game.addBot('bob', 'easy')).toThrow(GameError);
expect(() => ctx.game.addBot('alice', 'superhard')).toThrow(GameError);
ctx.game.addBot('alice', 'hard');
expect(ctx.last().players.map(p => p.login)).toEqual(['alice', botLogin('hard')]);
ctx.game.removeBot('alice');
expect(ctx.last().players).toHaveLength(1);
});
/** Odehraje zápas člověka, který myší dokonale sleduje míček, proti botovi. */
function playAgainstBot(level: BotLevel, maxSeconds: number) {
const ctx = setup(lcg(7));
ctx.game.join('alice');
ctx.game.addBot('alice', level);
ctx.game.start('alice');
ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
for (let t = 0; t < maxSeconds * 1000 && ctx.last().phase === 'playing'; t += TICK_MS) {
ctx.game.aim('alice', ctx.last().ball.y);
ctx.ticks(1);
}
return ctx;
}
test('lehký bot proti dokonalému hráči prohraje', () => {
const ctx = playAgainstBot('easy', 300);
expect(ctx.last()).toMatchObject({ phase: 'finished', winner: 'alice' });
});
test('nemožný bot neztratí ani bod', () => {
const ctx = playAgainstBot('impossible', 120);
expect(ctx.player('alice').score).toBe(0);
expect(ctx.player(botLogin('impossible')).score).toBe(0);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { ReactionDuel, DuelState, PENALTY_MS, CLICK_WINDOW_MS, ROUND_PAUSE_MS, MIN_DELAY_MS, LOBBY_TIMEOUT_MS } from '../reactionDuel';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
function setup(minPlayers = 2) {
const clock = fakeClock();
const states: DuelState[] = [];
const duel = new ReactionDuel(s => states.push(s), minPlayers, clock);
return { duel, clock, last: () => states[states.length - 1] };
}
/** Dohraje jedno kolo: počká na vyhlášení a nechá hráče kliknout s danými časy. */
function playRound(ctx: ReturnType<typeof setup>, reactions: Record<string, number>) {
ctx.clock.advance(MIN_DELAY_MS);
expect(ctx.last().phase).toBe('go');
const sorted = Object.entries(reactions).sort((a, b) => a[1] - b[1]);
let elapsed = 0;
for (const [login, ms] of sorted) {
ctx.clock.advance(ms - elapsed);
elapsed = ms;
ctx.duel.click(login);
}
}
describe('reakční hra', () => {
test('první hráč zakládá lobby a je hostitel', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.duel.join('bob');
expect(ctx.last()).toMatchObject({ phase: 'lobby', host: 'alice' });
expect(ctx.last().players.map(p => p.login)).toEqual(['alice', 'bob']);
});
test('hru může spustit jen hostitel a s dostatkem hráčů', () => {
const ctx = setup();
ctx.duel.join('alice');
expect(() => ctx.duel.start('alice')).toThrow(GameError);
ctx.duel.join('bob');
expect(() => ctx.duel.start('bob')).toThrow(GameError);
ctx.duel.start('alice');
expect(ctx.last()).toMatchObject({ phase: 'waiting', round: 1 });
});
test('do běžící hry se nelze připojit', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.duel.join('bob');
ctx.duel.start('alice');
expect(() => ctx.duel.join('carol')).toThrow(GameError);
});
test('vyhrává nejnižší součet časů, nejpomalejší je poslední', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.duel.join('bob');
ctx.duel.start('alice');
playRound(ctx, { alice: 200, bob: 300 });
expect(ctx.last()).toMatchObject({ phase: 'roundResult', round: 1 });
ctx.clock.advance(ROUND_PAUSE_MS);
playRound(ctx, { alice: 250, bob: 220 });
ctx.clock.advance(ROUND_PAUSE_MS);
playRound(ctx, { alice: 210, bob: 260 });
const state = ctx.last();
expect(state.phase).toBe('finished');
expect(state.players.find(p => p.login === 'alice')?.total).toBe(660);
expect(state.players.find(p => p.login === 'bob')?.total).toBe(780);
expect(state.winner).toBe('alice');
expect(state.loser).toBe('bob');
});
test('předčasný start a nekliknutí znamenají trestný čas', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.duel.join('bob');
ctx.duel.start('alice');
ctx.duel.click('alice'); // před vyhlášením
ctx.clock.advance(MIN_DELAY_MS);
ctx.clock.advance(CLICK_WINDOW_MS); // bob neklikne
const state = ctx.last();
expect(state.phase).toBe('roundResult');
expect(state.players.find(p => p.login === 'alice')?.rounds[0]).toEqual({ ms: null, falseStart: true });
expect(state.players.find(p => p.login === 'bob')?.rounds[0]).toEqual({ ms: null });
expect(state.players.every(p => p.total === PENALTY_MS)).toBe(true);
});
test('odchodem hostitele se lobby ruší', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.duel.join('bob');
ctx.duel.leave('alice');
expect(ctx.last()).toMatchObject({ phase: 'idle', players: [] });
});
test('neodstartované lobby po čase vyprší', () => {
const ctx = setup();
ctx.duel.join('alice');
ctx.clock.advance(LOBBY_TIMEOUT_MS);
expect(ctx.last().phase).toBe('idle');
});
});
+102
View File
@@ -0,0 +1,102 @@
import { SnakeGame, SnakeState, TICK_MS, COUNTDOWN_SECONDS, WIDTH, HEIGHT } from '../snakeGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
/** Náhoda vracející zadané hodnoty a pak nuly (jídlo pak padá do rohu [0,0]). */
const sequence = (...values: number[]) => () => values.length ? values.shift()! : 0;
/** Hodnoty náhody, které umístí jídlo na danou buňku. */
const foodAt = (x: number, y: number) => [x / WIDTH, y / HEIGHT];
function setup(random?: () => number) {
const clock = fakeClock(random);
const states: SnakeState[] = [];
const game = new SnakeGame(s => states.push(s), s => states.push(s), 1, clock);
const last = () => states[states.length - 1];
const player = (login: string) => last().players.find(p => p.login === login)!;
const startGame = (...logins: string[]) => {
logins.forEach(l => game.join(l));
game.start(logins[0]);
clock.advance(COUNTDOWN_SECONDS * 1000);
};
const ticks = (n: number) => clock.advance(n * TICK_MS);
return { game, clock, last, player, startGame, ticks };
}
describe('had', () => {
test('po odpočtu začne hra a had se posouvá o políčko za tick', () => {
const ctx = setup();
ctx.startGame('alice');
expect(ctx.last().phase).toBe('playing');
expect(ctx.player('alice').body).toEqual([[5, 10], [4, 10], [3, 10]]);
ctx.ticks(2);
expect(ctx.player('alice').body).toEqual([[7, 10], [6, 10], [5, 10]]);
});
test('zatáčí podle směru a obrat o 180° ignoruje', () => {
const ctx = setup();
ctx.startGame('alice');
ctx.game.turn('alice', 'left'); // obrat — ignoruje se
ctx.ticks(1);
expect(ctx.player('alice').body[0]).toEqual([6, 10]);
ctx.game.turn('alice', 'up');
ctx.ticks(1);
expect(ctx.player('alice').body[0]).toEqual([6, 9]);
});
test('nesmyslný směr se ignoruje', () => {
const ctx = setup();
ctx.startGame('alice');
ctx.game.turn('alice', 'diagonal');
ctx.ticks(1);
expect(ctx.player('alice').body[0]).toEqual([6, 10]);
});
test('sněděné jídlo prodlouží hada a přidá bod', () => {
const ctx = setup(sequence(...foodAt(7, 10)));
ctx.startGame('alice');
expect(ctx.last().food).toEqual([[7, 10]]);
ctx.ticks(2);
expect(ctx.player('alice').score).toBe(1);
expect(ctx.player('alice').body).toHaveLength(4);
expect(ctx.last().food).not.toContainEqual([7, 10]);
});
test('náraz do zdi ukončí sólo hru', () => {
const ctx = setup();
ctx.startGame('alice');
ctx.ticks(WIDTH);
expect(ctx.last().phase).toBe('finished');
expect(ctx.player('alice')).toMatchObject({ alive: false, place: 1 });
});
test('ve hře více hráčů vyhrává poslední přeživší', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
// alice startuje v řádku 6, nahoru do zdi dorazí v 7. ticku
ctx.game.turn('alice', 'up');
ctx.ticks(7);
expect(ctx.last().phase).toBe('finished');
expect(ctx.player('bob')).toMatchObject({ alive: true, place: 1 });
expect(ctx.player('alice')).toMatchObject({ alive: false, place: 2 });
});
test('čelní srážka zabije oba hady', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
// alice (5,6)→ doprava, bob (26,13)← doleva; po 7 tickách alice zatočí dolů
// a po dalších 7 se obě hlavy potkají na (12,13)
ctx.ticks(7);
ctx.game.turn('alice', 'down');
ctx.ticks(7);
const state = ctx.last();
expect(state.phase).toBe('finished');
expect(state.players.every(p => !p.alive)).toBe(true);
});
test('do běžící hry se nelze připojit', () => {
const ctx = setup();
ctx.startGame('alice');
expect(() => ctx.game.join('bob')).toThrow(GameError);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { TetrisGame, TetrisState, GarbageEvent, COUNTDOWN_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS } from '../tetrisGame';
import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock';
const BOARD = '0'.repeat(BOARD_WIDTH * (BOARD_HEIGHT - 1)) + '1'.repeat(BOARD_WIDTH);
function setup() {
const clock = fakeClock();
const all: TetrisState[] = [];
const watched: TetrisState[] = [];
const garbage: GarbageEvent[] = [];
const game = new TetrisGame(s => all.push(s), s => watched.push(s), e => garbage.push(e), 1, clock);
const last = () => all[all.length - 1];
const startGame = (...logins: string[]) => {
logins.forEach(l => game.join(l));
game.start(logins[0]);
clock.advance(COUNTDOWN_SECONDS * 1000);
};
return { game, clock, all, watched, garbage, last, startGame };
}
describe('tetris', () => {
test('po odpočtu začne hra se společným seedem', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
expect(ctx.last()).toMatchObject({ phase: 'playing' });
expect(typeof ctx.last().seed).toBe('number');
});
test('desky se sledujícím posílají sloučeně', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
ctx.game.update('alice', { board: BOARD, score: 100, lines: 1 });
ctx.game.update('bob', { board: BOARD, score: 40, lines: 0 });
expect(ctx.watched).toHaveLength(0);
ctx.clock.advance(WATCH_FLUSH_MS);
expect(ctx.watched).toHaveLength(1);
expect(ctx.watched[0].players.find(p => p.login === 'alice')).toMatchObject({ board: BOARD, score: 100, lines: 1 });
});
test('neplatná deska se zahodí', () => {
const ctx = setup();
ctx.startGame('alice');
ctx.game.update('alice', { board: 'x'.repeat(200), score: 5 });
ctx.game.update('alice', { board: '0'.repeat(10) });
ctx.clock.advance(WATCH_FLUSH_MS);
expect(ctx.watched).toHaveLength(0);
});
test('smazání více řádků pošle odpad soupeři, jeden řádek nic', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
ctx.game.cleared('alice', 1);
expect(ctx.garbage).toHaveLength(0);
ctx.game.cleared('alice', 4);
expect(ctx.garbage).toEqual([{ target: 'bob', from: 'alice', lines: 4 }]);
});
test('útoky jsou omezené frekvencí a velikostí', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
ctx.game.cleared('alice', 2);
ctx.game.cleared('alice', 2); // příliš brzy
ctx.game.cleared('alice', 99); // nesmysl
expect(ctx.garbage).toHaveLength(1);
ctx.clock.advance(MIN_ATTACK_GAP_MS);
ctx.game.cleared('alice', 3);
expect(ctx.garbage.map(g => g.lines)).toEqual([1, 2]);
});
test('poslední hráč ve hře vyhrává', () => {
const ctx = setup();
ctx.startGame('alice', 'bob', 'carol');
ctx.game.died('bob');
expect(ctx.last().phase).toBe('playing');
ctx.game.died('alice');
const state = ctx.last();
expect(state.phase).toBe('finished');
const place = (l: string) => state.players.find(p => p.login === l)?.place;
expect([place('carol'), place('alice'), place('bob')]).toEqual([1, 2, 3]);
});
test('mrtvý hráč neútočí a nedostává odpad', () => {
const ctx = setup();
ctx.startGame('alice', 'bob', 'carol');
ctx.game.died('bob');
ctx.game.cleared('bob', 4);
expect(ctx.garbage).toHaveLength(0);
ctx.game.cleared('alice', 4);
expect(ctx.garbage[0].target).toBe('carol');
});
test('hráč, který přestal posílat desku, je vyřazen', () => {
const ctx = setup();
ctx.startGame('alice', 'bob');
// alice hraje dál, bob zavřel okno
for (let t = 0; t <= PLAYER_TIMEOUT_MS + 5000; t += 2000) {
ctx.game.update('alice', { board: BOARD, score: 0, lines: 0 });
ctx.clock.advance(2000);
}
expect(ctx.last().phase).toBe('finished');
expect(ctx.last().players.find(p => p.login === 'alice')?.place).toBe(1);
});
test('do běžící hry se nelze připojit', () => {
const ctx = setup();
ctx.startGame('alice');
expect(() => ctx.game.join('bob')).toThrow(GameError);
});
});
+294
View File
@@ -0,0 +1,294 @@
import { Server } from "socket.io";
import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
/**
* Multiplayerový Tetris (battle) přes websocket.
*
* Každý hráč hraje vlastní desku v prohlížeči (kvůli odezvě ovládání), server koordinuje:
* lobby a odpočet, společný seed pro pořadí kostek, přeposílání desek soupeřům,
* útoky (smazané řádky → odpadní řádky náhodnému soupeři) a vyřazování.
* Vyhrává poslední hráč ve hře, sólo hra končí prohrou.
*
* Klientovi se věří (skóre, smazané řádky) — jde o kancelářskou hru; útoky jsou ale
* omezené co do velikosti i frekvence. Stav je v paměti, předpokládá jednu repliku.
*/
export const BOARD_WIDTH = 10;
export const BOARD_HEIGHT = 20;
export const MAX_PLAYERS = 8;
export const COUNTDOWN_SECONDS = 3;
/** Neodstartované lobby se po této době zruší (ms). */
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
/** Pojistka proti nekonečné hře (ms). */
export const MAX_GAME_MS = 15 * 60 * 1000;
/** Min. rozestup útoků jednoho hráče (ms) — víc řádků naráz ani rychleji fyzicky smazat nejde. */
export const MIN_ATTACK_GAP_MS = 150;
export const WATCH_ROOM = 'tetris-watchers';
/** Hráč, od kterého tak dlouho nepřišla deska (zavřený tab, výpadek), se bere jako vyřazený (ms). */
export const PLAYER_TIMEOUT_MS = 15_000;
/** Jak často se kontroluje, zda hráči žijí (ms). */
const WATCHDOG_MS = 5000;
/** Průběžné desky se sledujícím posílají nejvýš jednou za tuto dobu (ms). */
export const WATCH_FLUSH_MS = 100;
/** Kolik odpadních řádků pošle smazání daného počtu řádků naráz. */
export const GARBAGE_FOR_LINES: Record<number, number> = { 1: 0, 2: 1, 3: 2, 4: 4 };
export type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
export type TetrisPlayer = {
login: string,
/** Deska po řádcích shora, znak 0 = prázdno, 1–8 = barva (8 = odpad). */
board: string,
score: number,
lines: number,
alive: boolean,
place?: number,
};
export type TetrisState = {
phase: TetrisPhase,
host?: string,
players: TetrisPlayer[],
/** Seed generátoru kostek — všichni hráči dostanou stejné pořadí. */
seed?: number,
countdown?: number,
minPlayers: number,
};
export type GarbageEvent = { target: string, from: string, lines: number };
type InternalPlayer = TetrisPlayer & { lastAttack: number, lastSeen: number, deathOrder?: number };
const EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT);
const BOARD_PATTERN = new RegExp(`^[0-8]{${BOARD_WIDTH * BOARD_HEIGHT}}$`);
export class TetrisGame {
private phase: TetrisPhase = 'idle';
private host?: string;
private players = new Map<string, InternalPlayer>();
private seed?: number;
private countdown = 0;
private deaths = 0;
private timer: unknown;
/** Čeká už odeslání průběžného stavu sledujícím? */
private flushPending = false;
/**
* @param broadcastAll stav pro všechny (změny lobby a fáze)
* @param broadcastWatchers průběžný stav jen pro klienty s otevřenou hrou
* @param sendGarbage útok na hráče (doručuje se sledujícím, klient si vybere svůj)
*/
constructor(
private readonly broadcastAll: (state: TetrisState) => void,
private readonly broadcastWatchers: (state: TetrisState) => void,
private readonly sendGarbage: (event: GarbageEvent) => void,
private readonly minPlayers = 1,
private readonly clock: Clock = defaultClock,
) { }
getState(): TetrisState {
return {
phase: this.phase,
host: this.host,
players: [...this.players.values()].map(p => ({
login: p.login, board: p.board, score: p.score, lines: p.lines, alive: p.alive, place: p.place,
})),
seed: this.seed,
countdown: this.phase === 'countdown' ? this.countdown : undefined,
minPlayers: this.minPlayers,
};
}
join(login: string) {
if (this.phase === 'idle' || this.phase === 'finished') {
this.reset();
this.phase = 'lobby';
this.host = login;
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS);
} else if (this.phase !== 'lobby') {
throw new GameError('Hra už běží, počkejte na další.');
}
if (!this.players.has(login)) {
if (this.players.size >= MAX_PLAYERS) {
throw new GameError('Lobby je plné.');
}
this.players.set(login, { login, board: EMPTY_BOARD, score: 0, lines: 0, alive: true, lastAttack: 0, lastSeen: 0 });
}
this.emitAll();
}
leave(login: string) {
if (this.phase !== 'lobby' || !this.players.has(login)) {
return;
}
this.players.delete(login);
if (login === this.host || this.players.size === 0) {
this.cancel();
return;
}
this.emitAll();
}
start(login: string) {
if (this.phase !== 'lobby') {
throw new GameError('Není otevřené žádné lobby.');
}
if (login !== this.host) {
throw new GameError('Hru může spustit jen ten, kdo lobby založil.');
}
if (this.players.size < this.minPlayers) {
throw new GameError(`Ke hře je potřeba alespoň ${this.minPlayers} hráčů.`);
}
this.seed = Math.floor(this.clock.random() * 2 ** 31);
this.phase = 'countdown';
this.countdown = COUNTDOWN_SECONDS;
this.emitAll();
this.schedule(() => this.countdownStep(), 1000);
}
/** Průběžný stav desky hráče (posílá klient po položení kostky a průběžně). */
update(login: string, payload: unknown) {
const p = this.players.get(login);
if (!p || !p.alive || this.phase !== 'playing') return;
const { board, score, lines } = (payload ?? {}) as { board?: unknown, score?: unknown, lines?: unknown };
if (typeof board !== 'string' || !BOARD_PATTERN.test(board)) return;
p.lastSeen = this.clock.now();
p.board = board;
if (Number.isSafeInteger(score) && (score as number) >= 0) p.score = score as number;
if (Number.isSafeInteger(lines) && (lines as number) >= 0) p.lines = lines as number;
this.emitWatchers();
}
/** Hráč smazal řádky — případný útok jde náhodnému živému soupeři. */
cleared(login: string, count: unknown) {
const p = this.players.get(login);
if (!p || !p.alive || this.phase !== 'playing' || !Number.isInteger(count)) return;
const garbage = GARBAGE_FOR_LINES[count as number] ?? 0;
const now = this.clock.now();
if (garbage === 0 || now - p.lastAttack < MIN_ATTACK_GAP_MS) return;
p.lastAttack = now;
const targets = [...this.players.values()].filter(t => t.alive && t.login !== login);
if (targets.length === 0) return;
const target = targets[Math.floor(this.clock.random() * targets.length)];
this.sendGarbage({ target: target.login, from: login, lines: garbage });
}
/** Hráči přetekla deska. */
died(login: string) {
const p = this.players.get(login);
if (!p || !p.alive || this.phase !== 'playing') return;
p.alive = false;
p.deathOrder = ++this.deaths;
const alive = [...this.players.values()].filter(x => x.alive).length;
if (alive === 0 || (this.players.size > 1 && alive <= 1)) {
this.finish();
return;
}
this.emitAll();
}
private countdownStep() {
this.countdown--;
if (this.countdown > 0) {
this.emitAll();
this.schedule(() => this.countdownStep(), 1000);
return;
}
this.phase = 'playing';
const now = this.clock.now();
this.players.forEach(p => { p.lastSeen = now; });
this.emitAll();
this.schedule(() => this.finish(), MAX_GAME_MS);
this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
}
/** Vyřadí hráče, kteří přestali posílat desku (zavřeli okno, ztratili spojení). */
private watchdog() {
if (this.phase !== 'playing') return;
const now = this.clock.now();
for (const p of this.players.values()) {
if (p.alive && now - p.lastSeen > PLAYER_TIMEOUT_MS) this.died(p.login);
}
if (this.phase === 'playing') this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
}
/** Pořadí: živí první (podle skóre), pak vyřazení od posledního. */
private finish() {
const ranked = [...this.players.values()].sort((a, b) =>
(b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity) || b.score - a.score);
ranked.forEach((p, i) => { p.place = i + 1; });
this.phase = 'finished';
this.clearTimer();
this.emitAll();
}
private cancel() {
this.reset();
this.emitAll();
}
private reset() {
this.clearTimer();
this.phase = 'idle';
this.host = undefined;
this.players.clear();
this.seed = undefined;
this.deaths = 0;
}
private schedule(fn: () => void, ms: number) {
this.clearTimer();
this.timer = this.clock.setTimeout(() => {
this.timer = undefined;
fn();
}, ms);
}
private clearTimer() {
if (this.timer !== undefined) {
this.clock.clearTimeout(this.timer);
this.timer = undefined;
}
}
private emitAll() {
this.broadcastAll(this.getState());
}
/** Aktualizace desek se slučují, aby 8 hráčů nezahltilo sledující. */
private emitWatchers() {
if (this.flushPending) return;
this.flushPending = true;
this.clock.setTimeout(() => {
this.flushPending = false;
if (this.phase === 'playing') this.broadcastWatchers(this.getState());
}, WATCH_FLUSH_MS);
}
}
/** Zaregistruje websocket handlery Tetrisu. */
export function registerTetrisGame(io: Server) {
const game = new TetrisGame(
state => io.emit('tetris:state', state),
state => io.to(WATCH_ROOM).emit('tetris:state', state),
event => io.to(WATCH_ROOM).emit('tetris:garbage', event),
1, // Tetris se dá hrát i sólo
);
io.on('connection', socket => {
const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'tetris:error', action);
socket.on('tetris:sync', () => socket.emit('tetris:state', game.getState()));
socket.on('tetris:watch', () => {
socket.join(WATCH_ROOM);
socket.emit('tetris:state', game.getState());
});
socket.on('tetris:unwatch', () => socket.leave(WATCH_ROOM));
socket.on('tetris:join', handle(login => game.join(login)));
socket.on('tetris:leave', handle(login => game.leave(login)));
socket.on('tetris:start', handle(login => game.start(login)));
socket.on('tetris:update', handle((login, payload) => game.update(login, payload)));
socket.on('tetris:cleared', handle((login, payload) => game.cleared(login, (payload as { count?: unknown })?.count)));
socket.on('tetris:died', handle(login => game.died(login)));
});
}
+10
View File
@@ -1,6 +1,11 @@
import { DefaultEventsMap, Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
import { registerReactionDuel } from "./reactionDuel";
import { registerSnakeGame } from "./snakeGame";
import { registerTetrisGame } from "./tetrisGame";
import { registerPongGame } from "./pongGame";
import { registerDrawingGame } from "./drawingGame";
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
let pubClient: ReturnType<typeof createClient>;
@@ -28,6 +33,11 @@ export const initWebsocket = (server: any) => {
// console.log(`Client disconnected: ${socket.id}`);
});
});
registerReactionDuel(io);
registerSnakeGame(io);
registerTetrisGame(io);
registerPongGame(io);
registerDrawingGame(io);
return io;
};