feat: přepínání režimů a systému vítězství v Tetrisu
CI / Generate TypeScript types (push) Canceled after 0s
CI / Server unit tests (push) Canceled after 0s
CI / Build server (push) Canceled after 0s
CI / Build client (push) Canceled after 0s
CI / Playwright E2E tests (push) Canceled after 0s
CI / Build and push Docker image (push) Canceled after 0s
CI / Notify (push) Canceled after 0s

This commit is contained in:
mates committed 2026-10-19 16:49:56 +02:00
1 parent ed64fc60e4
commit 297cb80662
5 files changed
+277 -39

No files matched your search

+16
View File
@@ -117,3 +117,19 @@
columns: 2; columns: 2;
} }
} }
.tetris-game-modal .tetris-settings {
display: flex;
flex-wrap: wrap;
gap: 16px;
max-width: 640px;
> div {
flex: 1 1 180px;
}
.form-label {
margin-bottom: 2px;
font-weight: 600;
}
}
+111 -19
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Button, Modal, Table } from "react-bootstrap"; import { Button, Form, Modal, Table } from "react-bootstrap";
import { socket } from "../context/socket"; import { socket } from "../context/socket";
import { useAuth } from "../context/auth"; import { useAuth } from "../context/auth";
import { getToken } from "../Utils"; import { getToken } from "../Utils";
@@ -10,6 +10,9 @@ import "./TetrisGame.scss";
// Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI) // Typy musí odpovídat server/src/tetrisGame.ts (stav chodí přes websocket, ne přes OpenAPI)
type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished'; type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
type TetrisPlayer = { login: string, board: string, score: number, lines: number, alive: boolean, place?: number }; type TetrisPlayer = { login: string, board: string, score: number, lines: number, alive: boolean, place?: number };
type TetrisTargeting = 'random' | 'even' | 'payback';
type TetrisWinMode = 'survival' | 'score';
type TetrisSettings = { targeting: TetrisTargeting, winMode: TetrisWinMode, timeLimit: number };
type TetrisState = { type TetrisState = {
phase: TetrisPhase, phase: TetrisPhase,
host?: string, host?: string,
@@ -17,11 +20,30 @@ type TetrisState = {
seed?: number, seed?: number,
countdown?: number, countdown?: number,
minPlayers: number, minPlayers: number,
settings: TetrisSettings,
remainingMs?: number,
}; };
type GarbageEvent = { target: string, from: string, lines: number }; type GarbageEvent = { target: string, from: string, lines: number };
const OPEN_EVENT = 'luncher:open-tetris'; const OPEN_EVENT = 'luncher:open-tetris';
const TARGETING_LABELS: Record<TetrisTargeting, { label: string, help: string }> = {
random: { label: 'Náhodně (Random)', help: 'útok jde náhodnému soupeři' },
even: { label: 'Rovnoměrně (Even)', help: 'útok jde soupeři, který zatím dostal nejméně odpadu' },
payback: { label: 'Oplácení (Payback)', help: 'útok jde tomu, kdo vás naposledy napadl, jinak rovnoměrně' },
};
const WIN_MODE_LABELS: Record<TetrisWinMode, string> = {
survival: 'Poslední ve hře (Last man standing)',
score: 'Nejvyšší skóre',
};
/** Musí odpovídat TIME_LIMITS na serveru (s). */
const TIME_LIMITS = [120, 180, 300];
const formatTime = (ms: number) => {
const total = Math.ceil(ms / 1000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
};
/** Otevře okno Tetrisu. */ /** Otevře okno Tetrisu. */
export const openTetrisGame = () => window.dispatchEvent(new Event(OPEN_EVENT)); export const openTetrisGame = () => window.dispatchEvent(new Event(OPEN_EVENT));
@@ -115,6 +137,9 @@ export default function TetrisGame() {
const boardRef = useRef<HTMLCanvasElement>(null); const boardRef = useRef<HTMLCanvasElement>(null);
const nextRef = useRef<HTMLCanvasElement>(null); const nextRef = useRef<HTMLCanvasElement>(null);
const prevPhase = useRef<TetrisPhase>('idle'); const prevPhase = useRef<TetrisPhase>('idle');
/** Konec hry na skóre v čase prohlížeče (ze zbývajícího času, ať nevadí rozdíl hodin se serverem). */
const deadline = useRef<number | undefined>(undefined);
const [now, setNow] = useState(() => Date.now());
const icon = useGameIcon('tetris'); const icon = useGameIcon('tetris');
const send = useCallback((event: string, extra?: object) => { const send = useCallback((event: string, extra?: object) => {
@@ -128,7 +153,10 @@ export default function TetrisGame() {
}, [send]); }, [send]);
useEffect(() => { useEffect(() => {
const onState = (s: TetrisState) => setState(s); const onState = (s: TetrisState) => {
deadline.current = s.remainingMs !== undefined ? Date.now() + s.remainingMs : undefined;
setState(s);
};
const onError = (msg: string) => setError(msg); const onError = (msg: string) => setError(msg);
const onOpen = () => setOpen(true); const onOpen = () => setOpen(true);
const sync = () => socket.emit('tetris:sync'); const sync = () => socket.emit('tetris:sync');
@@ -226,6 +254,13 @@ export default function TetrisGame() {
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf);
}, [practice, phase, open, sendSnapshot, state?.seed]); }, [practice, phase, open, sendSnapshot, state?.seed]);
// Odpočet zbývajícího času hry na skóre
useEffect(() => {
if (phase !== 'playing' || state?.settings.winMode !== 'score') return;
const id = window.setInterval(() => setNow(Date.now()), 500);
return () => window.clearInterval(id);
}, [phase, state?.settings.winMode]);
// Heartbeat pro server (viz HEARTBEAT_MS) // Heartbeat pro server (viz HEARTBEAT_MS)
useEffect(() => { useEffect(() => {
if (practice || phase !== 'playing' || !isPlayer) return; if (practice || phase !== 'playing' || !isPlayer) return;
@@ -287,6 +322,46 @@ export default function TetrisGame() {
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));
const settings = state?.settings;
const isHost = login === state?.host;
const remaining = !practice && phase === 'playing' && deadline.current !== undefined
? Math.max(0, deadline.current - now) : undefined;
const configure = (change: Partial<TetrisSettings>) => send('tetris:configure', change);
const renderSettings = () => settings && (
<div className="tetris-settings">
<Form.Group>
<Form.Label>Míření odpadu</Form.Label>
{isHost ? (
<Form.Select size="sm" value={settings.targeting}
onChange={e => configure({ targeting: e.target.value as TetrisTargeting })}>
{Object.entries(TARGETING_LABELS).map(([value, { label }]) => <option key={value} value={value}>{label}</option>)}
</Form.Select>
) : <div><strong>{TARGETING_LABELS[settings.targeting].label}</strong></div>}
<Form.Text>{TARGETING_LABELS[settings.targeting].help}</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>Vítěz</Form.Label>
{isHost ? (
<Form.Select size="sm" value={settings.winMode}
onChange={e => configure({ winMode: e.target.value as TetrisWinMode })}>
{Object.entries(WIN_MODE_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</Form.Select>
) : <div><strong>{WIN_MODE_LABELS[settings.winMode]}</strong></div>}
</Form.Group>
{settings.winMode === 'score' && (
<Form.Group>
<Form.Label>Délka hry</Form.Label>
{isHost ? (
<Form.Select size="sm" value={settings.timeLimit}
onChange={e => configure({ timeLimit: Number(e.target.value) })}>
{TIME_LIMITS.map(t => <option key={t} value={t}>{t / 60} min</option>)}
</Form.Select>
) : <div><strong>{settings.timeLimit / 60} min</strong></div>}
</Form.Group>
)}
</div>
);
const renderPlaying = () => ( const renderPlaying = () => (
<div className="tetris-layout"> <div className="tetris-layout">
@@ -299,6 +374,12 @@ export default function TetrisGame() {
{flash && <div className="tetris-flash">{flash}</div>} {flash && <div className="tetris-flash">{flash}</div>}
</div> </div>
<div className="tetris-side"> <div className="tetris-side">
{remaining !== undefined && (
<>
<div className="tetris-label">Zbývá</div>
<div className="tetris-value">{formatTime(remaining)}</div>
</>
)}
<div className="tetris-label">Další</div> <div className="tetris-label">Další</div>
<canvas ref={nextRef} width={100} height={60} /> <canvas ref={nextRef} width={100} height={60} />
<div className="tetris-label">Skóre</div> <div className="tetris-label">Skóre</div>
@@ -335,8 +416,9 @@ export default function TetrisGame() {
return ( return (
<p> <p>
Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz, Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz,
pošle 1, 2 nebo 4 odpadní řádky soupeři, který jich zatím dostal nejméně. Smazáním řádků pošle soupeři 1, 2 nebo 4 odpadní řádky. Smazáním řádků lze zároveň odrazit odpad,
lze zároveň odrazit odpad, který na vás čeká. Vyhrává poslední hráč ve hře. který na vás čeká. Zakladatel lobby volí, komu útoky půjdou a zda vyhrává poslední
hráč ve hře, nebo nejvyšší skóre po časovém limitu.
Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe. Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe.
</p> </p>
); );
@@ -347,6 +429,7 @@ 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>
{renderSettings()}
</> </>
); );
case 'countdown': case 'countdown':
@@ -354,21 +437,30 @@ export default function TetrisGame() {
return renderPlaying(); return renderPlaying();
case 'finished': case 'finished':
return ( return (
<Table size="sm" className="tetris-results"> <>
<thead> {settings && (
<tr><th>#</th><th>Hráč</th><th>Skóre</th><th>Řádky</th></tr> <p className="text-muted">
</thead> Hodnocení: {WIN_MODE_LABELS[settings.winMode]}
<tbody> {settings.winMode === 'score' && ` (${settings.timeLimit / 60} min)`}
{ranked.map((p, i) => ( {' · '}míření: {TARGETING_LABELS[settings.targeting].label}
<tr key={p.login}> </p>
<td>{i < 3 ? MEDALS[i] : i + 1}</td> )}
<td>{p.login}{p.login === login && ' (vy)'}</td> <Table size="sm" className="tetris-results">
<td>{p.score}</td> <thead>
<td>{p.lines}</td> <tr><th>#</th><th>Hráč</th><th>Skóre</th><th>Řádky</th></tr>
</tr> </thead>
))} <tbody>
</tbody> {ranked.map((p, i) => (
</Table> <tr key={p.login}>
<td>{i < 3 ? MEDALS[i] : i + 1}</td>
<td>{p.login}{p.login === login && ' (vy)'}</td>
<td>{p.score}</td>
<td>{p.lines}</td>
</tr>
))}
</tbody>
</Table>
</>
); );
} }
}; };
-4
View File
@@ -1,4 +0,0 @@
[
"Tetris: odpadní řádky se mezi soupeře rozdělují rovnoměrně místo náhodně",
"Tetris: smazáním řádků lze odrazit odpad, který na vás čeká"
]
+66 -3
View File
@@ -1,4 +1,4 @@
import { TetrisGame, TetrisState, GarbageEvent, COUNTDOWN_SECONDS, MIN_ATTACK_GAP_MS, WATCH_FLUSH_MS, BOARD_WIDTH, BOARD_HEIGHT, PLAYER_TIMEOUT_MS } from '../tetrisGame'; 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 { GameError } from '../minigames'; import { GameError } from '../minigames';
import { fakeClock } from './helpers/fakeClock'; import { fakeClock } from './helpers/fakeClock';
@@ -11,12 +11,14 @@ function setup() {
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), 1, clock);
const last = () => all[all.length - 1]; const last = () => all[all.length - 1];
const startGame = (...logins: string[]) => { const startWith = (settings: Partial<TetrisSettings>, ...logins: string[]) => {
logins.forEach(l => game.join(l)); logins.forEach(l => game.join(l));
game.configure(logins[0], settings);
game.start(logins[0]); game.start(logins[0]);
clock.advance(COUNTDOWN_SECONDS * 1000); clock.advance(COUNTDOWN_SECONDS * 1000);
}; };
return { game, clock, all, watched, garbage, last, startGame }; const startGame = (...logins: string[]) => startWith({}, ...logins);
return { game, clock, all, watched, garbage, last, startGame, startWith };
} }
describe('tetris', () => { describe('tetris', () => {
@@ -127,6 +129,67 @@ describe('tetris', () => {
expect(ctx.last().players.find(p => p.login === 'alice')?.place).toBe(1); expect(ctx.last().players.find(p => p.login === 'alice')?.place).toBe(1);
}); });
test('nastavení mění jen zakladatel v lobby, neplatné hodnoty se ignorují', () => {
const ctx = setup();
ctx.game.join('alice');
ctx.game.join('bob');
expect(ctx.last().settings).toEqual(DEFAULT_SETTINGS);
expect(() => ctx.game.configure('bob', { targeting: 'random' })).toThrow(GameError);
ctx.game.configure('alice', { targeting: 'payback', winMode: 'nesmysl', timeLimit: 7 });
expect(ctx.last().settings).toEqual({ ...DEFAULT_SETTINGS, targeting: 'payback' });
ctx.game.start('alice');
expect(() => ctx.game.configure('alice', { winMode: 'score' })).toThrow(GameError);
});
test('random míří náhodně bez ohledu na obdržený odpad', () => {
const ctx = setup();
ctx.startWith({ targeting: 'random' }, 'alice', 'bob', 'carol');
ctx.game.cleared('alice', 4);
ctx.clock.advance(MIN_ATTACK_GAP_MS);
ctx.game.cleared('alice', 4); // random = 0 → pořád první soupeř
expect(ctx.garbage.map(g => g.target)).toEqual(['bob', 'bob']);
});
test('payback vrací útok poslednímu útočníkovi, jinak míří jako even', () => {
const ctx = setup();
ctx.startWith({ targeting: 'payback' }, 'alice', 'bob', 'carol');
ctx.game.cleared('carol', 2); // carol nikdo nenapadl → even → alice
ctx.game.cleared('alice', 2); // alice napadla carol → carol
ctx.game.cleared('bob', 2); // bob nikdo nenapadl → even (alice 1, carol 1) → alice
ctx.clock.advance(MIN_ATTACK_GAP_MS);
ctx.game.cleared('alice', 2); // naposledy ji napadl bob → bob
ctx.game.died('bob');
ctx.game.cleared('bob', 2); // mrtvý neútočí
ctx.game.cleared('carol', 2); // naposledy ji napadla alice → alice
expect(ctx.garbage.map(g => `${g.from}>${g.target}`)).toEqual(['carol>alice', 'alice>carol', 'bob>alice', 'alice>bob', 'carol>alice']);
});
test('hra na skóre končí časovým limitem a pořadí je podle skóre', () => {
const ctx = setup();
ctx.startWith({ winMode: 'score', timeLimit: 120 }, 'alice', 'bob', 'carol');
expect(ctx.last().remainingMs).toBe(120_000);
ctx.game.update('bob', { board: BOARD, score: 900, lines: 8 });
ctx.game.died('bob');
ctx.game.died('alice'); // zbývá jediný hráč, ale hraje se dál
expect(ctx.last().phase).toBe('playing');
ctx.game.update('alice', { board: BOARD, score: 100, lines: 1 }); // mrtvému se skóre už nemění
ctx.game.update('carol', { board: BOARD, score: 500, lines: 4 });
ctx.clock.advance(120_000);
const state = ctx.last();
expect(state.phase).toBe('finished');
const place = (l: string) => state.players.find(p => p.login === l)?.place;
expect([place('bob'), place('carol'), place('alice')]).toEqual([1, 2, 3]);
});
test('hra na skóre skončí dřív, když vypadnou všichni', () => {
const ctx = setup();
ctx.startWith({ winMode: 'score' }, 'alice', 'bob');
ctx.game.died('alice');
expect(ctx.last().phase).toBe('playing');
ctx.game.died('bob');
expect(ctx.last().phase).toBe('finished');
});
test('do běžící hry se nelze připojit', () => { test('do běžící hry se nelze připojit', () => {
const ctx = setup(); const ctx = setup();
ctx.startGame('alice'); ctx.startGame('alice');
+84 -13
View File
@@ -6,8 +6,9 @@ import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
* *
* 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, společný seed pro pořadí kostek, přeposílání desek soupeřům,
* útoky (smazané řádky → odpadní řádky soupeři, který zatím dostal nejméně odpadu) a vyřazování. * útoky (smazané řádky → odpadní řádky soupeři podle zvoleného režimu míření) a vyřazování.
* Vyhrává poslední hráč ve hře, sólo hra končí prohrou. * 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 * 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. * omezené co do velikosti i frekvence. Stav je v paměti, předpokládá jednu repliku.
@@ -36,6 +37,29 @@ export const GARBAGE_FOR_LINES: Record<number, number> = { 1: 0, 2: 1, 3: 2, 4:
export type TetrisPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished'; 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 = { export type TetrisPlayer = {
login: string, login: string,
/** Deska po řádcích shora, znak 0 = prázdno, 1–8 = barva (8 = odpad). */ /** Deska po řádcích shora, znak 0 = prázdno, 1–8 = barva (8 = odpad). */
@@ -54,6 +78,9 @@ export type TetrisState = {
seed?: number, seed?: number,
countdown?: number, countdown?: number,
minPlayers: 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 }; export type GarbageEvent = { target: string, from: string, lines: number };
@@ -62,8 +89,10 @@ type InternalPlayer = TetrisPlayer & {
lastAttack: number, lastAttack: number,
lastSeen: number, lastSeen: number,
deathOrder?: number, deathOrder?: number,
/** Kolik odpadních řádků už hráč od soupeřů dostal — podle toho se volí cíl útoku. */ /** Kolik odpadních řádků už hráč od soupeřů dostal (režim even). */
garbageReceived: number, garbageReceived: number,
/** Kdo hráče naposledy napadl (režim payback). */
lastAttacker?: string,
}; };
const EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT); const EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT);
@@ -76,6 +105,9 @@ export class TetrisGame {
private seed?: number; private seed?: number;
private countdown = 0; private countdown = 0;
private deaths = 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; private timer: unknown;
/** Čeká už odeslání průběžného stavu sledujícím? */ /** Čeká už odeslání průběžného stavu sledujícím? */
private flushPending = false; private flushPending = false;
@@ -103,6 +135,9 @@ export class TetrisGame {
seed: this.seed, seed: this.seed,
countdown: this.phase === 'countdown' ? this.countdown : undefined, countdown: this.phase === 'countdown' ? this.countdown : undefined,
minPlayers: this.minPlayers, minPlayers: this.minPlayers,
settings: { ...this.settings },
remainingMs: this.phase === 'playing' && this.endsAt !== undefined
? Math.max(0, this.endsAt - this.clock.now()) : undefined,
}; };
} }
@@ -136,6 +171,21 @@ export class TetrisGame {
this.emitAll(); 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) { start(login: string) {
if (this.phase !== 'lobby') { if (this.phase !== 'lobby') {
throw new GameError('Není otevřené žádné lobby.'); throw new GameError('Není otevřené žádné lobby.');
@@ -167,8 +217,7 @@ export class TetrisGame {
} }
/** /**
* Hráč smazal řádky — případný útok jde živému soupeři, který zatím dostal nejméně odpadu * Hráč smazal řádky — případný útok jde živému soupeři podle režimu míření (viz TetrisTargeting).
* (při shodě náhodně), aby se útoky rozkládaly rovnoměrně.
* *
* @param sent kolik řádků po odražení vlastního čekajícího odpadu zbylo na útok (počítá klient); * @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 * bez něj se pošle plný útok podle tabulky, víc než tabulka ale nikdy
@@ -183,13 +232,24 @@ export class TetrisGame {
p.lastAttack = now; p.lastAttack = now;
const opponents = [...this.players.values()].filter(t => t.alive && t.login !== login); const opponents = [...this.players.values()].filter(t => t.alive && t.login !== login);
if (opponents.length === 0) return; if (opponents.length === 0) return;
const least = Math.min(...opponents.map(t => t.garbageReceived)); const target = this.pickTarget(p, opponents);
const targets = opponents.filter(t => t.garbageReceived === least);
const target = targets[Math.floor(this.clock.random() * targets.length)];
target.garbageReceived += garbage; target.garbageReceived += garbage;
target.lastAttacker = login;
this.sendGarbage({ target: target.login, from: login, lines: garbage }); 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. */ /** Hráči přetekla deska. */
died(login: string) { died(login: string) {
const p = this.players.get(login); const p = this.players.get(login);
@@ -197,7 +257,9 @@ export class TetrisGame {
p.alive = false; p.alive = false;
p.deathOrder = ++this.deaths; p.deathOrder = ++this.deaths;
const alive = [...this.players.values()].filter(x => x.alive).length; const alive = [...this.players.values()].filter(x => x.alive).length;
if (alive === 0 || (this.players.size > 1 && alive <= 1)) { // 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(); this.finish();
return; return;
} }
@@ -214,8 +276,10 @@ export class TetrisGame {
this.phase = 'playing'; this.phase = 'playing';
const now = this.clock.now(); const now = this.clock.now();
this.players.forEach(p => { p.lastSeen = 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.emitAll();
this.schedule(() => this.finish(), MAX_GAME_MS); this.schedule(() => this.finish(), duration);
this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS); this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
} }
@@ -229,10 +293,15 @@ export class TetrisGame {
if (this.phase === 'playing') this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS); if (this.phase === 'playing') this.clock.setTimeout(() => this.watchdog(), WATCHDOG_MS);
} }
/** Pořadí: živí první (podle skóre), pak vyřazení od posledního. */ /**
* 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() { private finish() {
const ranked = [...this.players.values()].sort((a, b) => const survival = (a: InternalPlayer, b: InternalPlayer) => (b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity);
(b.deathOrder ?? Infinity) - (a.deathOrder ?? Infinity) || b.score - a.score); 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; }); ranked.forEach((p, i) => { p.place = i + 1; });
this.phase = 'finished'; this.phase = 'finished';
this.clearTimer(); this.clearTimer();
@@ -251,6 +320,7 @@ export class TetrisGame {
this.players.clear(); this.players.clear();
this.seed = undefined; this.seed = undefined;
this.deaths = 0; this.deaths = 0;
this.endsAt = undefined;
} }
private schedule(fn: () => void, ms: number) { private schedule(fn: () => void, ms: number) {
@@ -302,6 +372,7 @@ export function registerTetrisGame(io: Server) {
socket.on('tetris:unwatch', () => socket.leave(WATCH_ROOM)); socket.on('tetris:unwatch', () => socket.leave(WATCH_ROOM));
socket.on('tetris:join', handle(login => game.join(login))); socket.on('tetris:join', handle(login => game.join(login)));
socket.on('tetris:leave', handle(login => game.leave(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:start', handle(login => game.start(login)));
socket.on('tetris:update', handle((login, payload) => game.update(login, payload))); socket.on('tetris:update', handle((login, payload) => game.update(login, payload)));
socket.on('tetris:cleared', handle((login, payload) => { socket.on('tetris:cleared', handle((login, payload) => {