Files
Luncher/server/src/drawingGame.ts
T
batmanisko 6cb1214f08
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
feat: skryté multiplayerové minihry přes websocket
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.
2026-10-12 14:25:45 +02:00

402 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)));
});
}