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)));
});
}