Compare commits

...
2 Commits
Author SHA1 Message Date
mates 304c7656e9 fix: znemožnění spuštění Tetrisu v jednom hráči
CI / Generate TypeScript types (push) Successful in 10s
CI / Server unit tests (push) Successful in 26s
CI / Build server (push) Successful in 29s
CI / Build client (push) Successful in 43s
CI / Playwright E2E tests (push) Successful in 2m2s
CI / Build and push Docker image (push) Successful in 52s
CI / Notify (push) Successful in 4s
2026-10-20 09:47:00 +02:00
mates bc2cc75d7d fix: možnost připojit se ke hře Tetris během odpočtu před startem 2026-10-20 09:43:21 +02:00
3 changed files with 80 additions and 22 deletions

No files matched your search

+28 -13
View File
@@ -318,7 +318,9 @@ export default function TetrisGame() {
if (!login) return null; if (!login) return null;
const session = sessionRef.current; 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 showReturn = !open && isPlayer && inGame;
const others = practice ? [] : (state?.players ?? []).filter(p => !(isPlayer && p.login === login)); const others = practice ? [] : (state?.players ?? []).filter(p => !(isPlayer && p.login === login));
const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99)); const ranked = [...(state?.players ?? [])].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
@@ -429,10 +431,22 @@ export default function TetrisGame() {
<ul className="tetris-players"> <ul className="tetris-players">
{state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)} {state?.players.map(p => <li key={p.login}>{p.login}{p.login === state.host && ' 👑'}</li>)}
</ul> </ul>
{state && state.players.length < state.minPlayers &&
<p className="text-muted">Čeká se na další hráče… Pro hru o samotě použijte Trénink.</p>}
{renderSettings()} {renderSettings()}
</> </>
); );
case 'countdown': case 'countdown':
return (
<>
{!isPlayer && (
<p className="text-center">
Hra začne za <strong>{state?.countdown} s</strong> — ještě se můžete připojit.
</p>
)}
{renderPlaying()}
</>
);
case 'playing': case 'playing':
return renderPlaying(); return renderPlaying();
case 'finished': case 'finished':
@@ -482,17 +496,16 @@ export default function TetrisGame() {
if (phase === 'idle' || phase === 'finished') { if (phase === 'idle' || phase === 'finished') {
buttons.push(<Button key="new" onClick={() => send('tetris:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>); buttons.push(<Button key="new" onClick={() => send('tetris:join')}>{phase === 'idle' ? 'Založit lobby' : 'Nová hra'}</Button>);
} }
if (phase === 'lobby') { if (joinable && !isPlayer) {
if (!isPlayer) { buttons.push(<Button key="join" onClick={() => send('tetris:join')}>Připojit se</Button>);
buttons.push(<Button key="join" onClick={() => send('tetris:join')}>Připojit se</Button>); }
} else { if (phase === 'lobby' && isPlayer) {
buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tetris:leave')}> buttons.push(<Button key="leave" variant="outline-secondary" onClick={() => send('tetris:leave')}>
{login === state?.host ? 'Zrušit lobby' : 'Odejít'} {login === state?.host ? 'Zrušit lobby' : 'Odejít'}
</Button>); </Button>);
if (login === state?.host) { if (login === state?.host) {
buttons.push(<Button key="start" onClick={() => send('tetris:start')} buttons.push(<Button key="start" onClick={() => send('tetris:start')}
disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 1)}>Spustit hru</Button>); disabled={(state?.players.length ?? 0) < (state?.minPlayers ?? 2)}>Spustit hru</Button>);
}
} }
} }
buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>); buttons.push(<Button key="close" variant="secondary" onClick={() => setOpen(false)}>Zavřít</Button>);
@@ -503,7 +516,9 @@ export default function TetrisGame() {
<> <>
{showInvite && ( {showInvite && (
<button className="duel-invite tetris-invite" onClick={() => setOpen(true)}> <button className="duel-invite tetris-invite" onClick={() => setOpen(true)}>
{icon} <strong>{state?.host}</strong> zve na Tetris — připojit se {icon} {phase === 'countdown'
? <>Tetris začíná za <strong>{state?.countdown} s</strong> — připojit se</>
: <><strong>{state?.host}</strong> zve na Tetris — připojit se</>}
</button> </button>
)} )}
{showReturn && ( {showReturn && (
+38 -3
View File
@@ -1,15 +1,15 @@
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 { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock'; import { fakeClock } from './helpers/fakeClock';
const BOARD = '0'.repeat(BOARD_WIDTH * (BOARD_HEIGHT - 1)) + '1'.repeat(BOARD_WIDTH); const BOARD = '0'.repeat(BOARD_WIDTH * (BOARD_HEIGHT - 1)) + '1'.repeat(BOARD_WIDTH);
function setup() { function setup(minPlayers = 1) {
const clock = fakeClock(); const clock = fakeClock();
const all: TetrisState[] = []; const all: TetrisState[] = [];
const watched: TetrisState[] = []; const watched: TetrisState[] = [];
const garbage: GarbageEvent[] = []; const garbage: GarbageEvent[] = [];
const game = new TetrisGame(s => all.push(s), s => watched.push(s), e => garbage.push(e), 1, clock); const game = new TetrisGame(s => all.push(s), s => watched.push(s), e => garbage.push(e), minPlayers, clock);
const last = () => all[all.length - 1]; const last = () => all[all.length - 1];
const startWith = (settings: Partial<TetrisSettings>, ...logins: string[]) => { const startWith = (settings: Partial<TetrisSettings>, ...logins: string[]) => {
logins.forEach(l => game.join(l)); logins.forEach(l => game.join(l));
@@ -22,6 +22,16 @@ function setup() {
} }
describe('tetris', () => { describe('tetris', () => {
test('společnou hru nejde spustit jen s jedním hráčem', () => {
const ctx = setup(2);
ctx.game.join('alice');
expect(() => ctx.game.start('alice')).toThrow(GameError);
expect(ctx.last()).toMatchObject({ phase: 'lobby' });
ctx.game.join('bob');
ctx.game.start('alice');
expect(ctx.last()).toMatchObject({ phase: 'countdown' });
});
test('po odpočtu začne hra se společným seedem', () => { test('po odpočtu začne hra se společným seedem', () => {
const ctx = setup(); const ctx = setup();
ctx.startGame('alice', 'bob'); ctx.startGame('alice', 'bob');
@@ -195,4 +205,29 @@ describe('tetris', () => {
ctx.startGame('alice'); ctx.startGame('alice');
expect(() => ctx.game.join('bob')).toThrow(GameError); 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');
});
}); });
+14 -6
View File
@@ -1,11 +1,11 @@
import { Server } from "socket.io"; import { Server } from "socket.io";
import { Clock, defaultClock, GameError, gameHandler } from "./minigames"; import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } from "./minigames";
/** /**
* Multiplayerový Tetris (battle) přes websocket. * Multiplayerový Tetris (battle) přes websocket.
* *
* Každý hráč hraje vlastní desku v prohlížeči (kvůli odezvě ovládání), server koordinuje: * 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í. * ú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 * 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. * (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_WIDTH = 10;
export const BOARD_HEIGHT = 20; export const BOARD_HEIGHT = 20;
export const MAX_PLAYERS = 8; 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). */ /** Neodstartované lobby se po této době zruší (ms). */
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000; export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
/** Pojistka proti nekonečné hře (ms). */ /** Pojistka proti nekonečné hře (ms). */
@@ -121,7 +124,7 @@ export class TetrisGame {
private readonly broadcastAll: (state: TetrisState) => void, private readonly broadcastAll: (state: TetrisState) => void,
private readonly broadcastWatchers: (state: TetrisState) => void, private readonly broadcastWatchers: (state: TetrisState) => void,
private readonly sendGarbage: (event: GarbageEvent) => void, private readonly sendGarbage: (event: GarbageEvent) => void,
private readonly minPlayers = 1, private readonly minPlayers = 2,
private readonly clock: Clock = defaultClock, private readonly clock: Clock = defaultClock,
) { } ) { }
@@ -147,7 +150,7 @@ export class TetrisGame {
this.phase = 'lobby'; this.phase = 'lobby';
this.host = login; this.host = login;
this.schedule(() => this.cancel(), LOBBY_TIMEOUT_MS); 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ší.'); throw new GameError('Hra už běží, počkejte na další.');
} }
if (!this.players.has(login)) { if (!this.players.has(login)) {
@@ -155,6 +158,10 @@ export class TetrisGame {
throw new GameError('Lobby je plné.'); 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 }); 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(); this.emitAll();
} }
@@ -359,7 +366,8 @@ export function registerTetrisGame(io: Server) {
state => io.emit('tetris:state', state), state => io.emit('tetris:state', state),
state => io.to(WATCH_ROOM).emit('tetris:state', state), state => io.to(WATCH_ROOM).emit('tetris:state', state),
event => io.to(WATCH_ROOM).emit('tetris:garbage', event), event => io.to(WATCH_ROOM).emit('tetris:garbage', event),
1, // Tetris se dá hrát i sólo // Sólo hra je jen Trénink v prohlížeči, společná hra by blokovala ostatní
minPlayersForEnv(2),
); );
io.on('connection', socket => { io.on('connection', socket => {