feat: skryté multiplayerové minihry přes websocket
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
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.
This commit is contained in:
28 files changed
+5050
-2
No files matched your search
@@ -0,0 +1,250 @@
|
||||
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)));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user