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.
251 lines
8.1 KiB
TypeScript
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)));
|
|
});
|
|
}
|