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 = { 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(); 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>; 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))); }); }