Files
Luncher/server/src/reactionDuel.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

251 lines
8.1 KiB
TypeScript

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