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.
402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
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)));
|
||
});
|
||
}
|