Files
Luncher/server/src/tetrisGame.ts
T

392 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)));
});
}