diff --git a/client/src/components/TetrisGame.tsx b/client/src/components/TetrisGame.tsx index 77fb8f2..f6b617c 100644 --- a/client/src/components/TetrisGame.tsx +++ b/client/src/components/TetrisGame.tsx @@ -318,7 +318,9 @@ export default function TetrisGame() { if (!login) return null; const session = sessionRef.current; - const showInvite = !open && phase === 'lobby' && !isPlayer; + // Připojit se jde i během odpočtu před startem + const joinable = phase === 'lobby' || phase === 'countdown'; + const showInvite = !open && joinable && !isPlayer; const showReturn = !open && isPlayer && inGame; const others = practice ? [] : (state?.players ?? []).filter(p => !(isPlayer && p.login === login)); const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99)); @@ -433,6 +435,16 @@ export default function TetrisGame() { > ); case 'countdown': + return ( + <> + {!isPlayer && ( +
+ Hra začne za {state?.countdown} s — ještě se můžete připojit. +
+ )} + {renderPlaying()} + > + ); case 'playing': return renderPlaying(); case 'finished': @@ -482,17 +494,16 @@ export default function TetrisGame() { if (phase === 'idle' || phase === 'finished') { buttons.push(); } - if (phase === 'lobby') { - if (!isPlayer) { - buttons.push(); - } else { - buttons.push(); - if (login === state?.host) { - buttons.push(); - } + if (joinable && !isPlayer) { + buttons.push(); + } + if (phase === 'lobby' && isPlayer) { + buttons.push(); + if (login === state?.host) { + buttons.push(); } } buttons.push(); @@ -503,7 +514,9 @@ export default function TetrisGame() { <> {showInvite && ( )} {showReturn && ( diff --git a/server/src/tests/tetrisGame.test.ts b/server/src/tests/tetrisGame.test.ts index b0ccb4c..8745c7d 100644 --- a/server/src/tests/tetrisGame.test.ts +++ b/server/src/tests/tetrisGame.test.ts @@ -1,4 +1,4 @@ -import { TetrisGame, TetrisState, TetrisSettings, GarbageEvent, COUNTDOWN_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS, DEFAULT_SETTINGS } from '../tetrisGame'; +import { TetrisGame, TetrisState, TetrisSettings, GarbageEvent, COUNTDOWN_SECONDS, JOIN_GRACE_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS, DEFAULT_SETTINGS } from '../tetrisGame'; import { GameError } from '../minigames'; import { fakeClock } from './helpers/fakeClock'; @@ -195,4 +195,29 @@ describe('tetris', () => { ctx.startGame('alice'); expect(() => ctx.game.join('bob')).toThrow(GameError); }); + + test('během odpočtu se lze ještě připojit', () => { + const ctx = setup(); + ctx.game.join('alice'); + ctx.game.start('alice'); + ctx.clock.advance(1000); + ctx.game.join('bob'); + expect(ctx.last()).toMatchObject({ phase: 'countdown', countdown: COUNTDOWN_SECONDS - 1 }); + expect(ctx.last().players.map(p => p.login)).toEqual(['alice', 'bob']); + ctx.clock.advance((COUNTDOWN_SECONDS - 1) * 1000); + expect(ctx.last().phase).toBe('playing'); + }); + + test('připojení na poslední chvíli prodlouží odpočet', () => { + const ctx = setup(); + ctx.game.join('alice'); + ctx.game.start('alice'); + ctx.clock.advance((COUNTDOWN_SECONDS - 1) * 1000); + ctx.game.join('bob'); + expect(ctx.last()).toMatchObject({ phase: 'countdown', countdown: JOIN_GRACE_SECONDS }); + ctx.clock.advance((JOIN_GRACE_SECONDS - 1) * 1000); + expect(ctx.last().phase).toBe('countdown'); + ctx.clock.advance(1000); + expect(ctx.last().phase).toBe('playing'); + }); }); diff --git a/server/src/tetrisGame.ts b/server/src/tetrisGame.ts index 97496d3..4b8e23f 100644 --- a/server/src/tetrisGame.ts +++ b/server/src/tetrisGame.ts @@ -5,7 +5,7 @@ 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, + * 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. @@ -17,7 +17,10 @@ import { Clock, defaultClock, GameError, gameHandler } from "./minigames"; export const BOARD_WIDTH = 10; export const BOARD_HEIGHT = 20; export const MAX_PLAYERS = 8; -export const COUNTDOWN_SECONDS = 3; +/** 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). */ @@ -147,7 +150,7 @@ export class TetrisGame { this.phase = 'lobby'; this.host = login; this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS); - } else if (this.phase !== 'lobby') { + } else if (this.phase !== 'lobby' && this.phase !== 'countdown') { throw new GameError('Hra už běží, počkejte na další.'); } if (!this.players.has(login)) { @@ -155,6 +158,10 @@ export class TetrisGame { 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(); }