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,294 @@
|
||||
import { Server } from "socket.io";
|
||||
import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
|
||||
|
||||
/**
|
||||
* Multiplayerový Tetris (battle) přes websocket.
|
||||
*
|
||||
* Každý hráč hraje vlastní desku v prohlížeči (kvůli odezvě ovládání), server koordinuje:
|
||||
* lobby a odpočet, společný seed pro pořadí kostek, přeposílání desek soupeřům,
|
||||
* útoky (smazané řádky → odpadní řádky náhodnému soupeři) a vyřazování.
|
||||
* Vyhrává poslední hráč ve hře, sólo hra končí prohrou.
|
||||
*
|
||||
* Klientovi se věří (skóre, smazané řádky) — jde o kancelářskou hru; útoky jsou ale
|
||||
* omezené co do velikosti i frekvence. Stav je v paměti, předpokládá jednu repliku.
|
||||
*/
|
||||
|
||||
export const BOARD_WIDTH = 10;
|
||||
export const BOARD_HEIGHT = 20;
|
||||
export const MAX_PLAYERS = 8;
|
||||
export const COUNTDOWN_SECONDS = 3;
|
||||
/** Neodstartované lobby se po této době zruší (ms). */
|
||||
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
/** Pojistka proti nekonečné hře (ms). */
|
||||
export const MAX_GAME_MS = 15 * 60 * 1000;
|
||||
/** Min. rozestup útoků jednoho hráče (ms) — víc řádků naráz ani rychleji fyzicky smazat nejde. */
|
||||
export const MIN_ATTACK_GAP_MS = 150;
|
||||
export const WATCH_ROOM = 'tetris-watchers';
|
||||
/** Hráč, od kterého tak dlouho nepřišla deska (zavřený tab, výpadek), se bere jako vyřazený (ms). */
|
||||
export const PLAYER_TIMEOUT_MS = 15_000;
|
||||
/** Jak často se kontroluje, zda hráči žijí (ms). */
|
||||
const WATCHDOG_MS = 5000;
|
||||
/** Průběžné desky se sledujícím posílají nejvýš jednou za tuto dobu (ms). */
|
||||
export const WATCH_FLUSH_MS = 100;
|
||||
|
||||
/** Kolik odpadních řádků pošle smazání daného počtu řádků naráz. */
|
||||
export const GARBAGE_FOR_LINES: Record<number, number> = { 1: 0, 2: 1, 3: 2, 4: 4 };
|
||||
|
||||
export type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||
|
||||
export type TetrisPlayer = {
|
||||
login: string,
|
||||
/** Deska po řádcích shora, znak 0 = prázdno, 1–8 = barva (8 = odpad). */
|
||||
board: string,
|
||||
score: number,
|
||||
lines: number,
|
||||
alive: boolean,
|
||||
place?: number,
|
||||
};
|
||||
|
||||
export type TetrisState = {
|
||||
phase: TetrisPhase,
|
||||
host?: string,
|
||||
players: TetrisPlayer[],
|
||||
/** Seed generátoru kostek — všichni hráči dostanou stejné pořadí. */
|
||||
seed?: number,
|
||||
countdown?: number,
|
||||
minPlayers: number,
|
||||
};
|
||||
|
||||
export type GarbageEvent = { target: string, from: string, lines: number };
|
||||
|
||||
type InternalPlayer = TetrisPlayer & { lastAttack: number, lastSeen: number, deathOrder?: number };
|
||||
|
||||
const EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT);
|
||||
const BOARD_PATTERN = new RegExp(`^[0-8]{${BOARD_WIDTH * BOARD_HEIGHT}}$`);
|
||||
|
||||
export class TetrisGame {
|
||||
private phase: TetrisPhase = 'idle';
|
||||
private host?: string;
|
||||
private players = new Map<string, InternalPlayer>();
|
||||
private seed?: number;
|
||||
private countdown = 0;
|
||||
private deaths = 0;
|
||||
private timer: unknown;
|
||||
/** Čeká už odeslání průběžného stavu sledujícím? */
|
||||
private flushPending = false;
|
||||
|
||||
/**
|
||||
* @param broadcastAll stav pro všechny (změny lobby a fáze)
|
||||
* @param broadcastWatchers průběžný stav jen pro klienty s otevřenou hrou
|
||||
* @param sendGarbage útok na hráče (doručuje se sledujícím, klient si vybere svůj)
|
||||
*/
|
||||
constructor(
|
||||
private readonly broadcastAll: (state: TetrisState) => void,
|
||||
private readonly broadcastWatchers: (state: TetrisState) => void,
|
||||
private readonly sendGarbage: (event: GarbageEvent) => void,
|
||||
private readonly minPlayers = 1,
|
||||
private readonly clock: Clock = defaultClock,
|
||||
) { }
|
||||
|
||||
getState(): TetrisState {
|
||||
return {
|
||||
phase: this.phase,
|
||||
host: this.host,
|
||||
players: [...this.players.values()].map(p => ({
|
||||
login: p.login, board: p.board, score: p.score, lines: p.lines, alive: p.alive, place: p.place,
|
||||
})),
|
||||
seed: this.seed,
|
||||
countdown: this.phase === 'countdown' ? this.countdown : undefined,
|
||||
minPlayers: this.minPlayers,
|
||||
};
|
||||
}
|
||||
|
||||
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, board: EMPTY_BOARD, score: 0, lines: 0, alive: true, lastAttack: 0, lastSeen: 0 });
|
||||
}
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
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.emitAll();
|
||||
}
|
||||
|
||||
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.seed = Math.floor(this.clock.random() * 2 ** 31);
|
||||
this.phase = 'countdown';
|
||||
this.countdown = COUNTDOWN_SECONDS;
|
||||
this.emitAll();
|
||||
this.schedule(() => this.countdownStep(), 1000);
|
||||
}
|
||||
|
||||
/** Průběžný stav desky hráče (posílá klient po položení kostky a průběžně). */
|
||||
update(login: string, payload: unknown) {
|
||||
const p = this.players.get(login);
|
||||
if (!p || !p.alive || this.phase !== 'playing') return;
|
||||
const { board, score, lines } = (payload ?? {}) as { board?: unknown, score?: unknown, lines?: unknown };
|
||||
if (typeof board !== 'string' || !BOARD_PATTERN.test(board)) return;
|
||||
p.lastSeen = this.clock.now();
|
||||
p.board = board;
|
||||
if (Number.isSafeInteger(score) && (score as number) >= 0) p.score = score as number;
|
||||
if (Number.isSafeInteger(lines) && (lines as number) >= 0) p.lines = lines as number;
|
||||
this.emitWatchers();
|
||||
}
|
||||
|
||||
/** Hráč smazal řádky — případný útok jde náhodnému živému soupeři. */
|
||||
cleared(login: string, count: unknown) {
|
||||
const p = this.players.get(login);
|
||||
if (!p || !p.alive || this.phase !== 'playing' || !Number.isInteger(count)) return;
|
||||
const garbage = GARBAGE_FOR_LINES[count as number] ?? 0;
|
||||
const now = this.clock.now();
|
||||
if (garbage === 0 || now - p.lastAttack < MIN_ATTACK_GAP_MS) return;
|
||||
p.lastAttack = now;
|
||||
const targets = [...this.players.values()].filter(t => t.alive && t.login !== login);
|
||||
if (targets.length === 0) return;
|
||||
const target = targets[Math.floor(this.clock.random() * targets.length)];
|
||||
this.sendGarbage({ target: target.login, from: login, lines: garbage });
|
||||
}
|
||||
|
||||
/** Hráči přetekla deska. */
|
||||
died(login: string) {
|
||||
const p = this.players.get(login);
|
||||
if (!p || !p.alive || this.phase !== 'playing') return;
|
||||
p.alive = false;
|
||||
p.deathOrder = ++this.deaths;
|
||||
const alive = [...this.players.values()].filter(x => x.alive).length;
|
||||
if (alive === 0 || (this.players.size > 1 && alive <= 1)) {
|
||||
this.finish();
|
||||
return;
|
||||
}
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
private countdownStep() {
|
||||
this.countdown--;
|
||||
if (this.countdown > 0) {
|
||||
this.emitAll();
|
||||
this.schedule(() => this.countdownStep(), 1000);
|
||||
return;
|
||||
}
|
||||
this.phase = 'playing';
|
||||
const now = this.clock.now();
|
||||
this.players.forEach(p => { p.lastSeen = now; });
|
||||
this.emitAll();
|
||||
this.schedule(() => this.finish(), MAX_GAME_MS);
|
||||
this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
|
||||
}
|
||||
|
||||
/** Vyřadí hráče, kteří přestali posílat desku (zavřeli okno, ztratili spojení). */
|
||||
private watchdog() {
|
||||
if (this.phase !== 'playing') return;
|
||||
const now = this.clock.now();
|
||||
for (const p of this.players.values()) {
|
||||
if (p.alive && now - p.lastSeen > PLAYER_TIMEOUT_MS) this.died(p.login);
|
||||
}
|
||||
if (this.phase === 'playing') this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
|
||||
}
|
||||
|
||||
/** Pořadí: živí první (podle skóre), pak vyřazení od posledního. */
|
||||
private finish() {
|
||||
const ranked = [...this.players.values()].sort((a, b) =>
|
||||
(b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity) || b.score - a.score);
|
||||
ranked.forEach((p, i) => { p.place = i + 1; });
|
||||
this.phase = 'finished';
|
||||
this.clearTimer();
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
private cancel() {
|
||||
this.reset();
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.clearTimer();
|
||||
this.phase = 'idle';
|
||||
this.host = undefined;
|
||||
this.players.clear();
|
||||
this.seed = undefined;
|
||||
this.deaths = 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 emitAll() {
|
||||
this.broadcastAll(this.getState());
|
||||
}
|
||||
|
||||
/** Aktualizace desek se slučují, aby 8 hráčů nezahltilo sledující. */
|
||||
private emitWatchers() {
|
||||
if (this.flushPending) return;
|
||||
this.flushPending = true;
|
||||
this.clock.setTimeout(() => {
|
||||
this.flushPending = false;
|
||||
if (this.phase === 'playing') this.broadcastWatchers(this.getState());
|
||||
}, WATCH_FLUSH_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Zaregistruje websocket handlery Tetrisu. */
|
||||
export function registerTetrisGame(io: Server) {
|
||||
const game = new TetrisGame(
|
||||
state => io.emit('tetris:state', state),
|
||||
state => io.to(WATCH_ROOM).emit('tetris:state', state),
|
||||
event => io.to(WATCH_ROOM).emit('tetris:garbage', event),
|
||||
1, // Tetris se dá hrát i sólo
|
||||
);
|
||||
|
||||
io.on('connection', socket => {
|
||||
const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'tetris:error', action);
|
||||
socket.on('tetris:sync', () => socket.emit('tetris:state', game.getState()));
|
||||
socket.on('tetris:watch', () => {
|
||||
socket.join(WATCH_ROOM);
|
||||
socket.emit('tetris:state', game.getState());
|
||||
});
|
||||
socket.on('tetris:unwatch', () => socket.leave(WATCH_ROOM));
|
||||
socket.on('tetris:join', handle(login => game.join(login)));
|
||||
socket.on('tetris:leave', handle(login => game.leave(login)));
|
||||
socket.on('tetris:start', handle(login => game.start(login)));
|
||||
socket.on('tetris:update', handle((login, payload) => game.update(login, payload)));
|
||||
socket.on('tetris:cleared', handle((login, payload) => game.cleared(login, (payload as { count?: unknown })?.count)));
|
||||
socket.on('tetris:died', handle(login => game.died(login)));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user