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
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:
5 files changed
+277
-39
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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á"
|
|
||||||
]
|
|
||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
Reference in new issue
Block a user