392 lines
16 KiB
TypeScript
392 lines
16 KiB
TypeScript
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 (během něj se ještě dá připojit), společný seed pro pořadí kostek, přeposílání desek soupeřům,
|
||
* útoky (smazané řádky → odpadní řádky soupeři podle zvoleného režimu míření) a vyřazování.
|
||
* Zakladatel lobby volí režim míření a způsob hodnocení: buď vyhrává poslední hráč ve hře
|
||
* (sólo hra končí prohrou), nebo nejvyšší skóre po uplynutí časového limitu.
|
||
*
|
||
* 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;
|
||
/** Odpočet po spuštění hry — během něj se ještě mohou připojit opozdilci. */
|
||
export const COUNTDOWN_SECONDS = 5;
|
||
/** Kolik sekund odpočtu má nejméně zbývat po připojení dalšího hráče, ať se stihne připravit. */
|
||
export const JOIN_GRACE_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';
|
||
|
||
/**
|
||
* Komu jde útok:
|
||
* - random — náhodnému živému soupeři,
|
||
* - even — soupeři, který zatím dostal nejméně odpadu,
|
||
* - payback — tomu, kdo útočníka naposledy napadl (jinak jako even).
|
||
*/
|
||
export type TetrisTargeting = 'random' | 'even' | 'payback';
|
||
/** survival — vyhrává poslední ve hře; score — nejvyšší skóre po časovém limitu. */
|
||
export type TetrisWinMode = 'survival' | 'score';
|
||
|
||
export type TetrisSettings = {
|
||
targeting: TetrisTargeting,
|
||
winMode: TetrisWinMode,
|
||
/** Délka hry v sekundách (jen pro winMode = score). */
|
||
timeLimit: number,
|
||
};
|
||
|
||
export const TARGETING_MODES: TetrisTargeting[] = ['random', 'even', 'payback'];
|
||
export const WIN_MODES: TetrisWinMode[] = ['survival', 'score'];
|
||
/** Povolené délky hry na skóre (s). */
|
||
export const TIME_LIMITS = [120, 180, 300];
|
||
export const DEFAULT_SETTINGS: TetrisSettings = { targeting: 'even', winMode: 'survival', timeLimit: 180 };
|
||
|
||
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,
|
||
settings: TetrisSettings,
|
||
/** Zbývající čas hry na skóre (ms), jen ve fázi playing. */
|
||
remainingMs?: number,
|
||
};
|
||
|
||
export type GarbageEvent = { target: string, from: string, lines: number };
|
||
|
||
type InternalPlayer = TetrisPlayer & {
|
||
lastAttack: number,
|
||
lastSeen: number,
|
||
deathOrder?: number,
|
||
/** Kolik odpadních řádků už hráč od soupeřů dostal (režim even). */
|
||
garbageReceived: number,
|
||
/** Kdo hráče naposledy napadl (režim payback). */
|
||
lastAttacker?: string,
|
||
};
|
||
|
||
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 settings: TetrisSettings = { ...DEFAULT_SETTINGS };
|
||
/** Kdy skončí hra na skóre (čas z clock.now()). */
|
||
private endsAt?: number;
|
||
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,
|
||
settings: { ...this.settings },
|
||
remainingMs: this.phase === 'playing' && this.endsAt !== undefined
|
||
? Math.max(0, this.endsAt - this.clock.now()) : undefined,
|
||
};
|
||
}
|
||
|
||
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' && this.phase !== 'countdown') {
|
||
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, garbageReceived: 0 });
|
||
// Opozdilec připojený na poslední chvíli dostane čas se připravit (počet hráčů je omezený, odpočet se neprodlužuje donekonečna)
|
||
if (this.phase === 'countdown' && this.countdown < JOIN_GRACE_SECONDS) {
|
||
this.countdown = JOIN_GRACE_SECONDS;
|
||
}
|
||
}
|
||
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();
|
||
}
|
||
|
||
/** Nastavení hry — jen zakladatel a jen v lobby; neplatné hodnoty se ignorují. */
|
||
configure(login: string, payload: unknown) {
|
||
if (this.phase !== 'lobby') {
|
||
throw new GameError('Nastavení lze měnit jen v lobby.');
|
||
}
|
||
if (login !== this.host) {
|
||
throw new GameError('Nastavení může měnit jen ten, kdo lobby založil.');
|
||
}
|
||
const { targeting, winMode, timeLimit } = (payload ?? {}) as Partial<Record<keyof TetrisSettings, unknown>>;
|
||
if (TARGETING_MODES.includes(targeting as TetrisTargeting)) this.settings.targeting = targeting as TetrisTargeting;
|
||
if (WIN_MODES.includes(winMode as TetrisWinMode)) this.settings.winMode = winMode as TetrisWinMode;
|
||
if (TIME_LIMITS.includes(timeLimit as number)) this.settings.timeLimit = timeLimit as number;
|
||
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 živému soupeři podle režimu míření (viz TetrisTargeting).
|
||
*
|
||
* @param sent kolik řádků po odražení vlastního čekajícího odpadu zbylo na útok (počítá klient);
|
||
* bez něj se pošle plný útok podle tabulky, víc než tabulka ale nikdy
|
||
*/
|
||
cleared(login: string, count: unknown, sent?: unknown) {
|
||
const p = this.players.get(login);
|
||
if (!p || !p.alive || this.phase !== 'playing' || !Number.isInteger(count)) return;
|
||
const attack = GARBAGE_FOR_LINES[count as number] ?? 0;
|
||
const garbage = Number.isInteger(sent) ? Math.min(Math.max(sent as number, 0), attack) : attack;
|
||
const now = this.clock.now();
|
||
if (garbage === 0 || now - p.lastAttack < MIN_ATTACK_GAP_MS) return;
|
||
p.lastAttack = now;
|
||
const opponents = [...this.players.values()].filter(t => t.alive && t.login !== login);
|
||
if (opponents.length === 0) return;
|
||
const target = this.pickTarget(p, opponents);
|
||
target.garbageReceived += garbage;
|
||
target.lastAttacker = login;
|
||
this.sendGarbage({ target: target.login, from: login, lines: garbage });
|
||
}
|
||
|
||
private pickTarget(attacker: InternalPlayer, opponents: InternalPlayer[]): InternalPlayer {
|
||
const pick = (list: InternalPlayer[]) => list[Math.floor(this.clock.random() * list.length)];
|
||
if (this.settings.targeting === 'random') return pick(opponents);
|
||
if (this.settings.targeting === 'payback') {
|
||
const revenge = opponents.find(t => t.login === attacker.lastAttacker);
|
||
if (revenge) return revenge;
|
||
}
|
||
// even (a payback, dokud útočníka nikdo nenapadl): kdo dostal nejméně, při shodě náhodně
|
||
const least = Math.min(...opponents.map(t => t.garbageReceived));
|
||
return pick(opponents.filter(t => t.garbageReceived === least));
|
||
}
|
||
|
||
/** 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;
|
||
// Ve hře na skóre se hraje do limitu, dokud je ve hře aspoň jeden hráč
|
||
const lastStanding = this.settings.winMode === 'survival' && this.players.size > 1 && alive <= 1;
|
||
if (alive === 0 || lastStanding) {
|
||
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; });
|
||
const duration = this.settings.winMode === 'score' ? this.settings.timeLimit * 1000 : MAX_GAME_MS;
|
||
this.endsAt = this.settings.winMode === 'score' ? now + duration : undefined;
|
||
this.emitAll();
|
||
this.schedule(() => this.finish(), duration);
|
||
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í — survival: živí první (podle skóre), pak vyřazení od posledního;
|
||
* score: podle skóre, při shodě déle hrající.
|
||
*/
|
||
private finish() {
|
||
const survival = (a: InternalPlayer, b: InternalPlayer) => (b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity);
|
||
const ranked = [...this.players.values()].sort(this.settings.winMode === 'score'
|
||
? (a, b) => b.score - a.score || survival(a, b)
|
||
: (a, b) => survival(a, b) || 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;
|
||
this.endsAt = 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;
|
||
}
|
||
}
|
||
|
||
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:configure', handle((login, payload) => game.configure(login, payload)));
|
||
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) => {
|
||
const { count, sent } = (payload ?? {}) as { count?: unknown, sent?: unknown };
|
||
game.cleared(login, count, sent);
|
||
}));
|
||
socket.on('tetris:died', handle(login => game.died(login)));
|
||
});
|
||
}
|