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(); /** Výsledky aktuálního kola. */ private current = new Map(); 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))); }); }