feat: vytržení miniher do samostatného projektu
CI / Server unit tests (push) Successful in 26s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 2m24s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s
CI / Server unit tests (push) Successful in 26s
CI / Generate TypeScript types (push) Successful in 10s
CI / Build server (push) Successful in 28s
CI / Build client (push) Successful in 41s
CI / Playwright E2E tests (push) Successful in 2m24s
CI / Build and push Docker image (push) Successful in 44s
CI / Notify (push) Successful in 2s
This commit is contained in:
42 files changed
+136
-8224
No files matched your search
@@ -52,6 +52,10 @@
|
||||
# Bez hesla může refresh volat každý přihlášený uživatel (podléhá rate limitu).
|
||||
# REFRESH_BYPASS_PASSWORD=
|
||||
|
||||
# Adresa aplikace Lobby s minihrami. Skrytý odkaz (3× kliknutí na logo) ji otevře v nové záložce.
|
||||
# Bez vyplnění 3× kliknutí jen čeká na kód hry, která zůstala v Luncheru (kreslení).
|
||||
# LOBBY_URL='https://lobby.example.com'
|
||||
|
||||
# Admin heslo pro správu seznamu obchodů na stránce /objednani.
|
||||
# Bez hesla nelze přidávat ani odebírat obchody ze seznamu (POST/DELETE na /api/stores vrátí 403).
|
||||
# ADMIN_PASSWORD=
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"U oběda v restauraci si můžete zadat cenu (ikonka cenovky u vaší volby) — promítne se do měsíčního přehledu stravování a předvyplní se i v „Zaplatit za všechny“",
|
||||
"V lobby miniher (Pong, Reakční hra, Had, Tetris, Tanky) si hráči mohou psát v chatu, emotikony se vkládají jedním kliknutím 😉",
|
||||
"Tanky mají nově výchozí směrové ovládání — šipka nebo WSAD určuje směr jízdy na mapě; původní tankové ovládání (vpřed/vzad a otáčení) lze zapnout volbou Ovládání",
|
||||
"Tanky: větší mapy s více překážkami, vlastní tank je výrazně označený (při startu a po návratu do hry na něj ukazuje šipka) a pod hrací plochou je legenda bonusů"
|
||||
"Tanky: větší mapy s více překážkami, vlastní tank je výrazně označený (při startu a po návratu do hry na něj ukazuje šipka) a pod hrací plochou je legenda bonusů",
|
||||
"Minihry se přestěhovaly do vlastní aplikace 😉"
|
||||
]
|
||||
@@ -150,6 +150,9 @@ app.get("/api/config", (_req, res) => {
|
||||
features: {
|
||||
storeAdmin: !!process.env.ADMIN_PASSWORD,
|
||||
},
|
||||
links: {
|
||||
lobbyUrl: process.env.LOBBY_URL || null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-18
@@ -1,7 +1,7 @@
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { Socket } from "socket.io";
|
||||
import { getLogin } from "./auth";
|
||||
|
||||
/** Sdílené pomůcky pro websocketové minihry (reakční hra, had). */
|
||||
/** Pomůcky pro websocketové minihry (kreslení). Ostatní minihry jsou v samostatné aplikaci Lobby. */
|
||||
|
||||
/** Chyba určená hráči — posílá se zpět klientovi, nic se neloguje. */
|
||||
export class GameError extends Error { }
|
||||
@@ -60,19 +60,3 @@ export function gameHandler(socket: Socket, errorEvent: string, action: (login:
|
||||
|
||||
/** Minimální počet hráčů — lokálně lze hrát i sám. */
|
||||
export const minPlayersForEnv = (prod: number) => process.env.NODE_ENV === 'development' ? 1 : prod;
|
||||
|
||||
/**
|
||||
* Jednoduchý chat v lobby miniher — zprávy se nikde neukládají, server je jen
|
||||
* přepošle všem (klient si je filtruje podle hry).
|
||||
*/
|
||||
export function registerLobbyChat(io: Server) {
|
||||
io.on('connection', socket => {
|
||||
socket.on('lobbychat:send', gameHandler(socket, 'lobbychat:error', (login, payload) => {
|
||||
const { game, text } = (payload ?? {}) as { game?: unknown, text?: unknown };
|
||||
if (typeof game !== 'string' || typeof text !== 'string') return;
|
||||
const trimmed = text.trim().slice(0, 200);
|
||||
if (!trimmed) return;
|
||||
io.emit('lobbychat:message', { game: game.slice(0, 20), login, text: trimmed });
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
import { Server } from "socket.io";
|
||||
import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
|
||||
|
||||
/**
|
||||
* Pong 1v1 přes websocket.
|
||||
*
|
||||
* Server drží fyziku (míček, pálky, skóre) a posílá snímky sledujícím ~30× za sekundu;
|
||||
* hráči posílají jen směr pálky (nahoru / dolů / stát). Kdo první dá {@link WIN_SCORE}
|
||||
* bodů, vyhrává. Ostatní se mohou dívat. Stav je v paměti, předpokládá jednu repliku.
|
||||
*
|
||||
* Pálku lze ovládat klávesami (směr) i myší (cílová výška — pálka k ní rychle dojede).
|
||||
* Zakladatel lobby může místo soupeře přidat bota s nastavitelnou obtížností.
|
||||
*/
|
||||
|
||||
export const FIELD_WIDTH = 800;
|
||||
export const FIELD_HEIGHT = 500;
|
||||
export const PADDLE_HEIGHT = 90;
|
||||
export const PADDLE_WIDTH = 12;
|
||||
/** Vzdálenost pálky od kraje hřiště. */
|
||||
export const PADDLE_MARGIN = 20;
|
||||
export const BALL_SIZE = 12;
|
||||
export const TICK_MS = 33;
|
||||
/** Rychlosti v jednotkách hřiště za sekundu. */
|
||||
export const PADDLE_SPEED = 420;
|
||||
/** Rychlost pálky při ovládání myší — rychlé, ale ne okamžité přeskočení. */
|
||||
export const MOUSE_PADDLE_SPEED = 1600;
|
||||
export const BALL_START_SPEED = 330;
|
||||
export const BALL_MAX_SPEED = 900;
|
||||
/** Každý odpal míček zrychlí o tento násobek. */
|
||||
export const BALL_SPEEDUP = 1.07;
|
||||
/** Nejostřejší úhel odrazu od pálky (rad). */
|
||||
const MAX_BOUNCE_ANGLE = Math.PI / 3.2;
|
||||
export const WIN_SCORE = 5;
|
||||
export const COUNTDOWN_SECONDS = 3;
|
||||
/** Pauza po gólu, než se míček znovu rozehraje (ms). */
|
||||
export const SERVE_PAUSE_MS = 1000;
|
||||
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
export const WATCH_ROOM = 'pong-watchers';
|
||||
|
||||
export type PongPhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||
export type BotLevel = 'easy' | 'medium' | 'hard' | 'impossible';
|
||||
|
||||
type BotProfile = {
|
||||
label: string,
|
||||
/** Rychlost pálky bota. */
|
||||
speed: number,
|
||||
/** Jak často bot přehodnocuje, kam jet (ms) — simuluje reakční dobu. */
|
||||
reactMs: number,
|
||||
/** Max. odchylka míření (± jednotek hřiště). */
|
||||
error: number,
|
||||
/** Počítá, kam míček dopadne (včetně odrazů), nebo jen sleduje jeho výšku. */
|
||||
predict: boolean,
|
||||
};
|
||||
|
||||
export const BOT_PROFILES: Record<BotLevel, BotProfile> = {
|
||||
easy: { label: 'Lehký', speed: 250, reactMs: 320, error: 70, predict: false },
|
||||
medium: { label: 'Střední', speed: 340, reactMs: 200, error: 40, predict: true },
|
||||
hard: { label: 'Těžký', speed: 450, reactMs: 110, error: 20, predict: true },
|
||||
impossible: { label: 'Nemožný', speed: 900, reactMs: TICK_MS, error: 0, predict: true },
|
||||
};
|
||||
|
||||
/** Jméno bota — s emoji, aby nekolidovalo s přihlašovacími jmény. */
|
||||
export const botLogin = (level: BotLevel) => `🤖 Bot (${BOT_PROFILES[level].label})`;
|
||||
export type PaddleInput = 'up' | 'down' | 'stop';
|
||||
|
||||
export type PongPlayer = {
|
||||
login: string,
|
||||
/** 0 = levá pálka, 1 = pravá. */
|
||||
side: 0 | 1,
|
||||
/** Svislá pozice středu pálky. */
|
||||
y: number,
|
||||
score: number,
|
||||
/** Obtížnost, pokud jde o bota. */
|
||||
bot?: BotLevel,
|
||||
};
|
||||
|
||||
export type PongState = {
|
||||
phase: PongPhase,
|
||||
host?: string,
|
||||
players: PongPlayer[],
|
||||
ball: { x: number, y: number },
|
||||
countdown?: number,
|
||||
winner?: string,
|
||||
width: number,
|
||||
height: number,
|
||||
};
|
||||
|
||||
type InternalPlayer = PongPlayer & {
|
||||
input: PaddleInput,
|
||||
/** Cílová výška při ovládání myší (přebíjí klávesy, dokud se nezmáčkne šipka). */
|
||||
target?: number,
|
||||
/** Kam bot míří a kdy se znovu rozhodne. */
|
||||
botAim?: number,
|
||||
botThinkAt?: number,
|
||||
};
|
||||
|
||||
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
|
||||
|
||||
export class PongGame {
|
||||
private phase: PongPhase = 'idle';
|
||||
private host?: string;
|
||||
private players: InternalPlayer[] = [];
|
||||
private ball = { x: FIELD_WIDTH / 2, y: FIELD_HEIGHT / 2, vx: 0, vy: 0 };
|
||||
private countdown = 0;
|
||||
private winner?: string;
|
||||
/** Míček čeká na rozehrání po gólu. */
|
||||
private serveAt = 0;
|
||||
private lastTick = 0;
|
||||
private timer: unknown;
|
||||
|
||||
constructor(
|
||||
private readonly broadcastAll: (state: PongState) => void,
|
||||
private readonly broadcastWatchers: (state: PongState) => void,
|
||||
private readonly clock: Clock = defaultClock,
|
||||
) { }
|
||||
|
||||
getState(): PongState {
|
||||
return {
|
||||
phase: this.phase,
|
||||
host: this.host,
|
||||
players: this.players.map(p => ({ login: p.login, side: p.side, y: Math.round(p.y), score: p.score, bot: p.bot })),
|
||||
ball: { x: Math.round(this.ball.x), y: Math.round(this.ball.y) },
|
||||
countdown: this.phase === 'countdown' ? this.countdown : undefined,
|
||||
winner: this.winner,
|
||||
width: FIELD_WIDTH,
|
||||
height: FIELD_HEIGHT,
|
||||
};
|
||||
}
|
||||
|
||||
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') {
|
||||
throw new GameError('Hra už běží, počkejte na další.');
|
||||
}
|
||||
if (this.players.some(p => p.login === login)) return;
|
||||
if (this.players.length >= 2) {
|
||||
throw new GameError('Pong je jen pro dva hráče.');
|
||||
}
|
||||
this.players.push({ login, side: this.players.length as 0 | 1, y: FIELD_HEIGHT / 2, score: 0, input: 'stop' });
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
/** Zakladatel přidá do lobby bota místo druhého hráče. */
|
||||
addBot(login: string, level: unknown) {
|
||||
if (this.phase !== 'lobby') {
|
||||
throw new GameError('Není otevřené žádné lobby.');
|
||||
}
|
||||
if (login !== this.host) {
|
||||
throw new GameError('Bota může přidat jen ten, kdo lobby založil.');
|
||||
}
|
||||
if (typeof level !== 'string' || !(level in BOT_PROFILES)) {
|
||||
throw new GameError('Neznámá obtížnost bota.');
|
||||
}
|
||||
if (this.players.length >= 2) {
|
||||
throw new GameError('Pong je jen pro dva hráče.');
|
||||
}
|
||||
const bot = level as BotLevel;
|
||||
this.players.push({ login: botLogin(bot), side: 1, y: FIELD_HEIGHT / 2, score: 0, input: 'stop', bot });
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
/** Zakladatel odebere bota z lobby. */
|
||||
removeBot(login: string) {
|
||||
if (this.phase !== 'lobby' || login !== this.host) return;
|
||||
this.players = this.players.filter(p => !p.bot);
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
leave(login: string) {
|
||||
if (this.phase !== 'lobby' || !this.players.some(p => p.login === login)) return;
|
||||
if (login === this.host) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
this.players = this.players.filter(p => p.login !== login);
|
||||
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.length < 2) {
|
||||
throw new GameError('Na Pong jsou potřeba dva hráči.');
|
||||
}
|
||||
this.players.forEach(p => {
|
||||
p.y = FIELD_HEIGHT / 2;
|
||||
p.score = 0;
|
||||
p.input = 'stop';
|
||||
p.target = undefined;
|
||||
p.botAim = undefined;
|
||||
p.botThinkAt = undefined;
|
||||
});
|
||||
this.phase = 'countdown';
|
||||
this.countdown = COUNTDOWN_SECONDS;
|
||||
this.emitAll();
|
||||
this.schedule(() => this.countdownStep(), 1000);
|
||||
}
|
||||
|
||||
input(login: string, input: unknown) {
|
||||
const p = this.players.find(x => x.login === login);
|
||||
if (!p || p.bot || (input !== 'up' && input !== 'down' && input !== 'stop')) return;
|
||||
p.input = input;
|
||||
// Šipka přebíjí myš; puštění klávesy myš neruší
|
||||
if (input !== 'stop') p.target = undefined;
|
||||
}
|
||||
|
||||
/** Ovládání myší: pálka jede rychle k zadané výšce. */
|
||||
aim(login: string, y: unknown) {
|
||||
const p = this.players.find(x => x.login === login);
|
||||
if (!p || p.bot || typeof y !== 'number' || !Number.isFinite(y)) return;
|
||||
p.target = clamp(y, 0, FIELD_HEIGHT);
|
||||
p.input = 'stop';
|
||||
}
|
||||
|
||||
private countdownStep() {
|
||||
this.countdown--;
|
||||
if (this.countdown > 0) {
|
||||
this.emitAll();
|
||||
this.schedule(() => this.countdownStep(), 1000);
|
||||
return;
|
||||
}
|
||||
this.phase = 'playing';
|
||||
this.serve(Math.round(this.clock.random()) as 0 | 1);
|
||||
this.lastTick = this.clock.now();
|
||||
this.emitAll();
|
||||
this.schedule(() => this.step(), TICK_MS);
|
||||
}
|
||||
|
||||
/** Míček do středu, po pauze letí k dané straně pod náhodným úhlem. */
|
||||
private serve(towards: 0 | 1) {
|
||||
const angle = (this.clock.random() - 0.5) * (Math.PI / 3);
|
||||
const dir = towards === 0 ? -1 : 1;
|
||||
this.ball = {
|
||||
x: FIELD_WIDTH / 2,
|
||||
y: FIELD_HEIGHT / 2,
|
||||
vx: dir * BALL_START_SPEED * Math.cos(angle),
|
||||
vy: BALL_START_SPEED * Math.sin(angle),
|
||||
};
|
||||
this.serveAt = this.clock.now() + SERVE_PAUSE_MS;
|
||||
}
|
||||
|
||||
private step() {
|
||||
const now = this.clock.now();
|
||||
// Skutečně uplynulý čas, ať zpožděný tick nezpomalí hru (max. 100 ms proti skokům)
|
||||
const dt = Math.min(0.1, (now - this.lastTick) / 1000);
|
||||
this.lastTick = now;
|
||||
|
||||
for (const p of this.players) {
|
||||
if (p.bot) {
|
||||
this.moveBot(p, now, dt);
|
||||
} else if (p.target !== undefined) {
|
||||
p.y = this.approach(p.y, p.target, MOUSE_PADDLE_SPEED * dt);
|
||||
} else {
|
||||
const dir = p.input === 'up' ? -1 : p.input === 'down' ? 1 : 0;
|
||||
p.y = clamp(p.y + dir * PADDLE_SPEED * dt, PADDLE_HEIGHT / 2, FIELD_HEIGHT - PADDLE_HEIGHT / 2);
|
||||
}
|
||||
}
|
||||
|
||||
if (now >= this.serveAt) {
|
||||
this.moveBall(dt);
|
||||
}
|
||||
if (this.phase !== 'playing') return;
|
||||
this.emitWatchers();
|
||||
this.schedule(() => this.step(), TICK_MS);
|
||||
}
|
||||
|
||||
/** Posune pálku k cíli nejvýš o `maxStep`, v mezích hřiště. */
|
||||
private approach(y: number, target: number, maxStep: number) {
|
||||
const goal = clamp(target, PADDLE_HEIGHT / 2, FIELD_HEIGHT - PADDLE_HEIGHT / 2);
|
||||
return Math.abs(goal - y) <= maxStep ? goal : y + Math.sign(goal - y) * maxStep;
|
||||
}
|
||||
|
||||
/** Bot se v intervalu reakční doby rozhodne, kam jet, a jede tam svou rychlostí. */
|
||||
private moveBot(p: InternalPlayer, now: number, dt: number) {
|
||||
const profile = BOT_PROFILES[p.bot!];
|
||||
if (p.botThinkAt === undefined || now >= p.botThinkAt) {
|
||||
p.botThinkAt = now + profile.reactMs;
|
||||
const b = this.ball;
|
||||
const incoming = p.side === 0 ? b.vx < 0 : b.vx > 0;
|
||||
let aim: number;
|
||||
if (!incoming || now < this.serveAt) {
|
||||
// Míček letí pryč — vrátit se doprostřed
|
||||
aim = FIELD_HEIGHT / 2;
|
||||
} else if (profile.predict) {
|
||||
aim = this.predictY(p.side);
|
||||
} else {
|
||||
aim = b.y;
|
||||
}
|
||||
p.botAim = aim + (this.clock.random() * 2 - 1) * profile.error;
|
||||
}
|
||||
p.y = this.approach(p.y, p.botAim ?? FIELD_HEIGHT / 2, profile.speed * dt);
|
||||
}
|
||||
|
||||
/** Výška, ve které míček dorazí k pálce dané strany (s odrazy od horní a dolní stěny). */
|
||||
private predictY(side: 0 | 1): number {
|
||||
const b = this.ball;
|
||||
const r = BALL_SIZE / 2;
|
||||
const faceX = side === 0 ? PADDLE_MARGIN + PADDLE_WIDTH + r : FIELD_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH - r;
|
||||
const t = (faceX - b.x) / b.vx;
|
||||
if (!Number.isFinite(t) || t < 0) return b.y;
|
||||
// Odrazy "rozbalíme": pohyb v pásmu [r, H - r] je periodický s periodou 2 × výška pásma
|
||||
const span = FIELD_HEIGHT - 2 * r;
|
||||
const period = 2 * span;
|
||||
let y = ((b.y + b.vy * t - r) % period + period) % period;
|
||||
if (y > span) y = period - y;
|
||||
return y + r;
|
||||
}
|
||||
|
||||
private moveBall(dt: number) {
|
||||
const b = this.ball;
|
||||
b.x += b.vx * dt;
|
||||
b.y += b.vy * dt;
|
||||
const r = BALL_SIZE / 2;
|
||||
|
||||
if (b.y - r < 0) { b.y = r; b.vy = Math.abs(b.vy); }
|
||||
if (b.y + r > FIELD_HEIGHT) { b.y = FIELD_HEIGHT - r; b.vy = -Math.abs(b.vy); }
|
||||
|
||||
for (const p of this.players) {
|
||||
const faceX = p.side === 0 ? PADDLE_MARGIN + PADDLE_WIDTH : FIELD_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH;
|
||||
const movingTowards = p.side === 0 ? b.vx < 0 : b.vx > 0;
|
||||
const reached = p.side === 0 ? b.x - r <= faceX : b.x + r >= faceX;
|
||||
const behind = p.side === 0 ? b.x + r < PADDLE_MARGIN : b.x - r > FIELD_WIDTH - PADDLE_MARGIN;
|
||||
if (!movingTowards || !reached || behind) continue;
|
||||
const offset = (b.y - p.y) / (PADDLE_HEIGHT / 2 + r);
|
||||
if (Math.abs(offset) > 1) continue;
|
||||
// Úhel odrazu podle místa zásahu na pálce, rychlost postupně roste
|
||||
const speed = Math.min(BALL_MAX_SPEED, Math.hypot(b.vx, b.vy) * BALL_SPEEDUP);
|
||||
const angle = offset * MAX_BOUNCE_ANGLE;
|
||||
b.vx = (p.side === 0 ? 1 : -1) * speed * Math.cos(angle);
|
||||
b.vy = speed * Math.sin(angle);
|
||||
b.x = p.side === 0 ? faceX + r : faceX - r;
|
||||
}
|
||||
|
||||
if (b.x < 0) this.point(1);
|
||||
else if (b.x > FIELD_WIDTH) this.point(0);
|
||||
}
|
||||
|
||||
private point(side: 0 | 1) {
|
||||
const scorer = this.players.find(p => p.side === side);
|
||||
if (!scorer) return;
|
||||
scorer.score++;
|
||||
if (scorer.score >= WIN_SCORE) {
|
||||
this.winner = scorer.login;
|
||||
this.phase = 'finished';
|
||||
this.clearTimer();
|
||||
this.emitAll();
|
||||
return;
|
||||
}
|
||||
// Podává se k tomu, kdo bod ztratil
|
||||
this.serve(side === 0 ? 1 : 0);
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
private cancel() {
|
||||
this.reset();
|
||||
this.emitAll();
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.clearTimer();
|
||||
this.phase = 'idle';
|
||||
this.host = undefined;
|
||||
this.players = [];
|
||||
this.winner = undefined;
|
||||
this.ball = { x: FIELD_WIDTH / 2, y: FIELD_HEIGHT / 2, vx: 0, vy: 0 };
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
private emitWatchers() {
|
||||
this.broadcastWatchers(this.getState());
|
||||
}
|
||||
|
||||
/** Pro testy: nastaví míček na konkrétní pozici a rychlost. */
|
||||
setBallForTest(x: number, y: number, vx: number, vy: number) {
|
||||
this.ball = { x, y, vx, vy };
|
||||
this.serveAt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Zaregistruje websocket handlery Pongu. */
|
||||
export function registerPongGame(io: Server) {
|
||||
const game = new PongGame(
|
||||
state => io.emit('pong:state', state),
|
||||
state => io.to(WATCH_ROOM).emit('pong:state', state),
|
||||
);
|
||||
|
||||
io.on('connection', socket => {
|
||||
const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'pong:error', action);
|
||||
socket.on('pong:sync', () => socket.emit('pong:state', game.getState()));
|
||||
socket.on('pong:watch', () => {
|
||||
socket.join(WATCH_ROOM);
|
||||
socket.emit('pong:state', game.getState());
|
||||
});
|
||||
socket.on('pong:unwatch', () => socket.leave(WATCH_ROOM));
|
||||
socket.on('pong:join', handle(login => game.join(login)));
|
||||
socket.on('pong:leave', handle(login => game.leave(login)));
|
||||
socket.on('pong:start', handle(login => game.start(login)));
|
||||
socket.on('pong:input', handle((login, payload) => game.input(login, (payload as { input?: unknown })?.input)));
|
||||
socket.on('pong:aim', handle((login, payload) => game.aim(login, (payload as { y?: unknown })?.y)));
|
||||
socket.on('pong:addBot', handle((login, payload) => game.addBot(login, (payload as { level?: unknown })?.level)));
|
||||
socket.on('pong:removeBot', handle(login => game.removeBot(login)));
|
||||
});
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
import { Server } from "socket.io";
|
||||
import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } from "./minigames";
|
||||
|
||||
/**
|
||||
* Reakční hra — krátká multiplayerová hra přes websocket.
|
||||
*
|
||||
* Hráči se přidají do lobby, pak proběhnou {@link ROUNDS} kola: server po náhodné
|
||||
* prodlevě vyhlásí "TEĎ!" a měří čas od vyhlášení do přijetí kliknutí. Kdo klikne
|
||||
* před vyhlášením (předčasný start) nebo neklikne vůbec, dostane trestný čas.
|
||||
* Pořadí určuje součet časů — nejnižší vyhrává.
|
||||
*
|
||||
* Veškeré časování drží server, klient jen posílá kliknutí. Stav je v paměti procesu,
|
||||
* takže hra předpokládá jednu repliku serveru (broadcast přes Redis adapter funguje,
|
||||
* ale akce z jiné repliky by stav neviděly).
|
||||
*/
|
||||
|
||||
export const ROUNDS = 3;
|
||||
/** Trestný čas za předčasný start nebo nekliknutí (ms). */
|
||||
export const PENALTY_MS = 2000;
|
||||
/** Jak dlouho po vyhlášení se čeká na kliknutí (ms). */
|
||||
export const CLICK_WINDOW_MS = 2000;
|
||||
export const MIN_DELAY_MS = 2000;
|
||||
export const MAX_DELAY_MS = 6000;
|
||||
/** Pauza mezi koly pro zobrazení výsledku kola (ms). */
|
||||
export const ROUND_PAUSE_MS = 2500;
|
||||
/** Neodstartované lobby se po této době zruší (ms). */
|
||||
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
export const MAX_PLAYERS = 12;
|
||||
|
||||
export type DuelPhase = 'idle' | 'lobby' | 'waiting' | 'go' | 'roundResult' | 'finished';
|
||||
|
||||
export type RoundResult = {
|
||||
/** Reakční čas v ms, nebo null při předčasném startu / nekliknutí. */
|
||||
ms: number | null,
|
||||
falseStart?: boolean,
|
||||
};
|
||||
|
||||
export type DuelPlayer = {
|
||||
login: string,
|
||||
/** Výsledky jednotlivých kol. */
|
||||
rounds: RoundResult[],
|
||||
/** Součet časů včetně trestů. */
|
||||
total: number,
|
||||
};
|
||||
|
||||
export type DuelState = {
|
||||
phase: DuelPhase,
|
||||
host?: string,
|
||||
round: number,
|
||||
totalRounds: number,
|
||||
players: DuelPlayer[],
|
||||
/** Login posledního hráče (jen ve fázi finished). */
|
||||
loser?: string,
|
||||
/** Login nejrychlejšího hráče celé hry (jen ve fázi finished). */
|
||||
winner?: string,
|
||||
minPlayers: number,
|
||||
};
|
||||
|
||||
export class ReactionDuel {
|
||||
private phase: DuelPhase = 'idle';
|
||||
private host?: string;
|
||||
private round = 0;
|
||||
private players = new Map<string, DuelPlayer>();
|
||||
/** Výsledky aktuálního kola. */
|
||||
private current = new Map<string, RoundResult>();
|
||||
private goAt = 0;
|
||||
private timer: unknown;
|
||||
private loser?: string;
|
||||
private winner?: string;
|
||||
|
||||
constructor(
|
||||
private readonly broadcast: (state: DuelState) => void,
|
||||
private readonly minPlayers = 2,
|
||||
private readonly clock: Clock = defaultClock,
|
||||
) { }
|
||||
|
||||
getState(): DuelState {
|
||||
return {
|
||||
phase: this.phase,
|
||||
host: this.host,
|
||||
round: this.round,
|
||||
totalRounds: ROUNDS,
|
||||
players: [...this.players.values()].map(p => ({ ...p, rounds: [...p.rounds] })),
|
||||
loser: this.loser,
|
||||
winner: this.winner,
|
||||
minPlayers: this.minPlayers,
|
||||
};
|
||||
}
|
||||
|
||||
/** Přidá hráče; pokud žádná hra neběží, založí nové lobby. */
|
||||
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') {
|
||||
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, rounds: [], total: 0 });
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Odebere hráče z lobby; odchodem zakladatele se lobby ruší. */
|
||||
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.emit();
|
||||
}
|
||||
|
||||
/** Spustí hru, smí jen zakladatel lobby. */
|
||||
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.nextRound();
|
||||
}
|
||||
|
||||
/** Zpracuje kliknutí hráče. */
|
||||
click(login: string) {
|
||||
if (!this.players.has(login) || this.current.has(login)) {
|
||||
return;
|
||||
}
|
||||
if (this.phase === 'waiting') {
|
||||
this.current.set(login, { ms: null, falseStart: true });
|
||||
} else if (this.phase === 'go') {
|
||||
this.current.set(login, { ms: Math.max(0, this.clock.now() - this.goAt) });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (this.current.size === this.players.size && this.phase === 'go') {
|
||||
this.endRound();
|
||||
} else {
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
|
||||
private nextRound() {
|
||||
this.round++;
|
||||
this.current.clear();
|
||||
this.phase = 'waiting';
|
||||
const delay = MIN_DELAY_MS + Math.floor(this.clock.random() * (MAX_DELAY_MS - MIN_DELAY_MS));
|
||||
this.schedule(() => this.go(), delay);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private go() {
|
||||
this.phase = 'go';
|
||||
this.goAt = this.clock.now();
|
||||
if (this.current.size === this.players.size) {
|
||||
// Všichni odstartovali předčasně, není na co čekat
|
||||
this.endRound();
|
||||
return;
|
||||
}
|
||||
this.schedule(() => this.endRound(), CLICK_WINDOW_MS);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private endRound() {
|
||||
for (const player of this.players.values()) {
|
||||
const result = this.current.get(player.login) ?? { ms: null };
|
||||
player.rounds.push(result);
|
||||
player.total += result.ms ?? PENALTY_MS;
|
||||
}
|
||||
this.current.clear();
|
||||
if (this.round >= ROUNDS) {
|
||||
this.finish();
|
||||
return;
|
||||
}
|
||||
this.phase = 'roundResult';
|
||||
this.schedule(() => this.nextRound(), ROUND_PAUSE_MS);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private finish() {
|
||||
const ranked = [...this.players.values()].sort((a, b) => a.total - b.total);
|
||||
this.winner = ranked[0]?.login;
|
||||
this.loser = ranked.length > 1 ? ranked[ranked.length - 1].login : undefined;
|
||||
this.phase = 'finished';
|
||||
this.clearTimer();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private cancel() {
|
||||
this.reset();
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.clearTimer();
|
||||
this.phase = 'idle';
|
||||
this.host = undefined;
|
||||
this.round = 0;
|
||||
this.players.clear();
|
||||
this.current.clear();
|
||||
this.loser = undefined;
|
||||
this.winner = 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** Rozešle stav všem klientům; výsledky kola se zveřejní až po jeho skončení. */
|
||||
private emit() {
|
||||
this.broadcast(this.getState());
|
||||
}
|
||||
}
|
||||
|
||||
/** Zaregistruje websocket handlery reakčního duelu. */
|
||||
export function registerReactionDuel(io: Server) {
|
||||
const duel = new ReactionDuel(state => io.emit('duel:state', state), minPlayersForEnv(2));
|
||||
|
||||
io.on('connection', socket => {
|
||||
const handle = (action: (login: string) => void) => gameHandler(socket, 'duel:error', action);
|
||||
socket.on('duel:sync', () => socket.emit('duel:state', duel.getState()));
|
||||
socket.on('duel:join', handle(login => duel.join(login)));
|
||||
socket.on('duel:leave', handle(login => duel.leave(login)));
|
||||
socket.on('duel:start', handle(login => duel.start(login)));
|
||||
socket.on('duel:click', handle(login => duel.click(login)));
|
||||
});
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
import { Server } from "socket.io";
|
||||
import { Clock, defaultClock, GameError, gameHandler } from "./minigames";
|
||||
|
||||
/**
|
||||
* Multiplayerový had přes websocket.
|
||||
*
|
||||
* Server drží celou simulaci (tick smyčka, kolize, jídlo), klienti posílají jen směr.
|
||||
* Had umírá nárazem do zdi, do sebe nebo do jiného hada (čelní srážka zabije oba).
|
||||
* Pořadí: kdo přežije déle, je výš; při stejném okamžiku smrti rozhoduje délka.
|
||||
* Hra končí, když zbyde poslední živý had (sólo hra až smrtí), nebo po {@link MAX_TICKS}.
|
||||
*
|
||||
* Průběžné snímky chodí jen do místnosti {@link WATCH_ROOM} (klienti s otevřeným oknem),
|
||||
* ostatním jen změny lobby kvůli pozvánce. Stav je v paměti, předpokládá jednu repliku.
|
||||
*/
|
||||
|
||||
export const WIDTH = 32;
|
||||
export const HEIGHT = 20;
|
||||
export const TICK_MS = 120;
|
||||
/** Maximální délka hry (~3 minuty). */
|
||||
export const MAX_TICKS = Math.round(3 * 60 * 1000 / TICK_MS);
|
||||
export const COUNTDOWN_SECONDS = 3;
|
||||
export const START_LENGTH = 3;
|
||||
export const MAX_PLAYERS = 8;
|
||||
/** Neodstartované lobby se po této době zruší (ms). */
|
||||
export const LOBBY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
/** Kolik změn směru si server pamatuje dopředu (rychlé zatáčky v jednom ticku). */
|
||||
const MAX_QUEUED_TURNS = 2;
|
||||
export const WATCH_ROOM = 'snake-watchers';
|
||||
|
||||
export type Direction = 'up' | 'down' | 'left' | 'right';
|
||||
export type Cell = [number, number];
|
||||
export type SnakePhase = 'idle' | 'lobby' | 'countdown' | 'playing' | 'finished';
|
||||
|
||||
export type SnakePlayer = {
|
||||
login: string,
|
||||
/** Index barvy hada (klient si mapuje na paletu). */
|
||||
color: number,
|
||||
/** Tělo od hlavy po ocas. */
|
||||
body: Cell[],
|
||||
alive: boolean,
|
||||
/** Počet snědených jídel. */
|
||||
score: number,
|
||||
/** Konečné umístění (jen ve fázi finished). */
|
||||
place?: number,
|
||||
};
|
||||
|
||||
export type SnakeState = {
|
||||
phase: SnakePhase,
|
||||
host?: string,
|
||||
players: SnakePlayer[],
|
||||
food: Cell[],
|
||||
tick: number,
|
||||
maxTicks: number,
|
||||
/** Zbývající sekundy odpočtu (ve fázi countdown). */
|
||||
countdown?: number,
|
||||
width: number,
|
||||
height: number,
|
||||
minPlayers: number,
|
||||
};
|
||||
|
||||
type InternalPlayer = SnakePlayer & {
|
||||
dir: Direction,
|
||||
queue: Direction[],
|
||||
deathTick?: number,
|
||||
};
|
||||
|
||||
const DELTAS: Record<Direction, Cell> = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
|
||||
const OPPOSITE: Record<Direction, Direction> = { up: 'down', down: 'up', left: 'right', right: 'left' };
|
||||
const isDirection = (d: unknown): d is Direction => typeof d === 'string' && d in DELTAS;
|
||||
const key = ([x, y]: Cell) => `${x},${y}`;
|
||||
|
||||
export class SnakeGame {
|
||||
private phase: SnakePhase = 'idle';
|
||||
private host?: string;
|
||||
private players = new Map<string, InternalPlayer>();
|
||||
private food: Cell[] = [];
|
||||
private tickNo = 0;
|
||||
private countdown = 0;
|
||||
private timer: unknown;
|
||||
|
||||
/**
|
||||
* @param broadcastAll stav pro všechny (změny lobby a fáze)
|
||||
* @param broadcastWatchers průběžné snímky jen pro klienty se otevřenou hrou
|
||||
*/
|
||||
constructor(
|
||||
private readonly broadcastAll: (state: SnakeState) => void,
|
||||
private readonly broadcastWatchers: (state: SnakeState) => void,
|
||||
private readonly minPlayers = 1,
|
||||
private readonly clock: Clock = defaultClock,
|
||||
) { }
|
||||
|
||||
getState(): SnakeState {
|
||||
return {
|
||||
phase: this.phase,
|
||||
host: this.host,
|
||||
players: [...this.players.values()].map(p => ({
|
||||
login: p.login,
|
||||
color: p.color,
|
||||
body: p.body.map(c => [c[0], c[1]] as Cell),
|
||||
alive: p.alive,
|
||||
score: p.score,
|
||||
place: p.place,
|
||||
})),
|
||||
food: this.food.map(c => [c[0], c[1]] as Cell),
|
||||
tick: this.tickNo,
|
||||
maxTicks: MAX_TICKS,
|
||||
countdown: this.phase === 'countdown' ? this.countdown : undefined,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
minPlayers: this.minPlayers,
|
||||
};
|
||||
}
|
||||
|
||||
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') {
|
||||
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é.');
|
||||
}
|
||||
const used = new Set([...this.players.values()].map(p => p.color));
|
||||
let color = 0;
|
||||
while (used.has(color)) color++;
|
||||
this.players.set(login, { login, color, body: [], alive: true, score: 0, dir: 'right', queue: [] });
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
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.spawn();
|
||||
this.phase = 'countdown';
|
||||
this.countdown = COUNTDOWN_SECONDS;
|
||||
this.emitAll();
|
||||
this.schedule(() => this.countdownStep(), 1000);
|
||||
}
|
||||
|
||||
/** Zařadí změnu směru; obrat o 180° a opakování stejného směru se ignorují. */
|
||||
turn(login: string, dir: unknown) {
|
||||
const p = this.players.get(login);
|
||||
if (!p || !p.alive || !isDirection(dir) || (this.phase !== 'playing' && this.phase !== 'countdown')) {
|
||||
return;
|
||||
}
|
||||
const last = p.queue.length ? p.queue[p.queue.length - 1] : p.dir;
|
||||
if (dir === last || dir === OPPOSITE[last] || p.queue.length >= MAX_QUEUED_TURNS) {
|
||||
return;
|
||||
}
|
||||
p.queue.push(dir);
|
||||
}
|
||||
|
||||
/** Rozmístí hady do řádků, střídavě zleva doprava a zprava doleva. */
|
||||
private spawn() {
|
||||
const list = [...this.players.values()];
|
||||
list.forEach((p, i) => {
|
||||
const y = Math.floor((i + 1) * HEIGHT / (list.length + 1));
|
||||
const fromLeft = i % 2 === 0;
|
||||
const headX = fromLeft ? 2 + START_LENGTH : WIDTH - 3 - START_LENGTH;
|
||||
p.dir = fromLeft ? 'right' : 'left';
|
||||
p.body = Array.from({ length: START_LENGTH }, (_, k) => [fromLeft ? headX - k : headX + k, y] as Cell);
|
||||
p.alive = true;
|
||||
p.score = 0;
|
||||
p.queue = [];
|
||||
p.place = undefined;
|
||||
p.deathTick = undefined;
|
||||
});
|
||||
this.food = [];
|
||||
this.tickNo = 0;
|
||||
this.refillFood();
|
||||
}
|
||||
|
||||
private countdownStep() {
|
||||
this.countdown--;
|
||||
if (this.countdown > 0) {
|
||||
this.emitAll();
|
||||
this.schedule(() => this.countdownStep(), 1000);
|
||||
return;
|
||||
}
|
||||
this.phase = 'playing';
|
||||
this.emitAll();
|
||||
this.schedule(() => this.step(), TICK_MS);
|
||||
}
|
||||
|
||||
/** Jeden krok simulace. */
|
||||
private step() {
|
||||
this.tickNo++;
|
||||
const alive = [...this.players.values()].filter(p => p.alive);
|
||||
const foodKeys = new Set(this.food.map(key));
|
||||
const eaten = new Set<string>();
|
||||
|
||||
// Pohyb: nová hlava, ocas se odebere, pokud had nejí
|
||||
for (const p of alive) {
|
||||
const next = p.queue.shift();
|
||||
if (next) p.dir = next;
|
||||
const [dx, dy] = DELTAS[p.dir];
|
||||
const head: Cell = [p.body[0][0] + dx, p.body[0][1] + dy];
|
||||
p.body.unshift(head);
|
||||
if (foodKeys.has(key(head))) {
|
||||
eaten.add(key(head));
|
||||
p.score++;
|
||||
} else {
|
||||
p.body.pop();
|
||||
}
|
||||
}
|
||||
|
||||
// Kolize se vyhodnotí až po pohybu všech, aby nezáleželo na pořadí hráčů
|
||||
const occupied = new Map<string, number>();
|
||||
for (const p of alive) {
|
||||
for (const c of p.body) occupied.set(key(c), (occupied.get(key(c)) ?? 0) + 1);
|
||||
}
|
||||
for (const p of alive) {
|
||||
const [x, y] = p.body[0];
|
||||
const outside = x < 0 || y < 0 || x >= WIDTH || y >= HEIGHT;
|
||||
if (outside || (occupied.get(key(p.body[0])) ?? 0) > 1) {
|
||||
p.alive = false;
|
||||
p.deathTick = this.tickNo;
|
||||
}
|
||||
}
|
||||
for (const p of alive) {
|
||||
if (!p.alive) p.body = [];
|
||||
}
|
||||
|
||||
this.food = this.food.filter(c => !eaten.has(key(c)));
|
||||
this.refillFood();
|
||||
|
||||
const stillAlive = alive.filter(p => p.alive).length;
|
||||
const multiplayer = this.players.size > 1;
|
||||
if (stillAlive === 0 || (multiplayer && stillAlive <= 1) || this.tickNo >= MAX_TICKS) {
|
||||
this.finish();
|
||||
return;
|
||||
}
|
||||
this.emitWatchers();
|
||||
this.schedule(() => this.step(), TICK_MS);
|
||||
}
|
||||
|
||||
/** Na hrací ploše je tolik jídel, kolik je hráčů. */
|
||||
private refillFood() {
|
||||
const taken = new Set<string>(this.food.map(key));
|
||||
for (const p of this.players.values()) p.body.forEach(c => taken.add(key(c)));
|
||||
const target = Math.max(1, this.players.size);
|
||||
let attempts = 0;
|
||||
while (this.food.length < target && attempts++ < 1000) {
|
||||
const cell: Cell = [Math.floor(this.clock.random() * WIDTH), Math.floor(this.clock.random() * HEIGHT)];
|
||||
if (!taken.has(key(cell))) {
|
||||
taken.add(key(cell));
|
||||
this.food.push(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finish() {
|
||||
const ranked = [...this.players.values()].sort((a, b) =>
|
||||
(b.deathTick ?? Infinity) - (a.deathTick ?? Infinity) || 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.food = [];
|
||||
this.tickNo = 0;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
private emitWatchers() {
|
||||
this.broadcastWatchers(this.getState());
|
||||
}
|
||||
}
|
||||
|
||||
/** Zaregistruje websocket handlery hada. */
|
||||
export function registerSnakeGame(io: Server) {
|
||||
const game = new SnakeGame(
|
||||
state => io.emit('snake:state', state),
|
||||
state => io.to(WATCH_ROOM).emit('snake:state', state),
|
||||
1, // had se dá hrát i sólo
|
||||
);
|
||||
|
||||
io.on('connection', socket => {
|
||||
const handle = (action: (login: string, payload: unknown) => void) => gameHandler(socket, 'snake:error', action);
|
||||
socket.on('snake:sync', () => socket.emit('snake:state', game.getState()));
|
||||
socket.on('snake:watch', () => {
|
||||
socket.join(WATCH_ROOM);
|
||||
socket.emit('snake:state', game.getState());
|
||||
});
|
||||
socket.on('snake:unwatch', () => socket.leave(WATCH_ROOM));
|
||||
socket.on('snake:join', handle(login => game.join(login)));
|
||||
socket.on('snake:leave', handle(login => game.leave(login)));
|
||||
socket.on('snake:start', handle(login => game.start(login)));
|
||||
socket.on('snake:turn', handle((login, payload) => game.turn(login, (payload as { dir?: unknown })?.dir)));
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,124 +0,0 @@
|
||||
import { PongGame, PongState, TICK_MS, COUNTDOWN_SECONDS, FIELD_HEIGHT, PADDLE_MARGIN, PADDLE_WIDTH, PADDLE_HEIGHT, WIN_SCORE, SERVE_PAUSE_MS, botLogin, BotLevel } from '../pongGame';
|
||||
import { GameError } from '../minigames';
|
||||
import { fakeClock } from './helpers/fakeClock';
|
||||
|
||||
/** Deterministická pseudonáhoda, ať se boti nechovají pokaždé stejně jako s konstantou. */
|
||||
function lcg(seed = 1) {
|
||||
let x = seed;
|
||||
return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
}
|
||||
|
||||
function setup(random?: () => number) {
|
||||
const clock = fakeClock(random);
|
||||
const states: PongState[] = [];
|
||||
const game = new PongGame(s => states.push(s), s => states.push(s), clock);
|
||||
const last = () => states[states.length - 1];
|
||||
const player = (login: string) => last().players.find(p => p.login === login)!;
|
||||
const startGame = () => {
|
||||
game.join('alice');
|
||||
game.join('bob');
|
||||
game.start('alice');
|
||||
clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||
};
|
||||
const ticks = (n: number) => clock.advance(n * TICK_MS);
|
||||
return { game, clock, last, player, startGame, ticks };
|
||||
}
|
||||
|
||||
describe('pong', () => {
|
||||
test('je jen pro dva hráče a hru spouští zakladatel', () => {
|
||||
const ctx = setup();
|
||||
ctx.game.join('alice');
|
||||
expect(() => ctx.game.start('alice')).toThrow(GameError);
|
||||
ctx.game.join('bob');
|
||||
expect(() => ctx.game.join('carol')).toThrow(GameError);
|
||||
expect(() => ctx.game.start('bob')).toThrow(GameError);
|
||||
ctx.game.start('alice');
|
||||
expect(ctx.last().phase).toBe('countdown');
|
||||
});
|
||||
|
||||
test('pálka se hýbe podle vstupu a nevyjede z hřiště', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.input('alice', 'up');
|
||||
ctx.ticks(10);
|
||||
expect(ctx.player('alice').y).toBeLessThan(FIELD_HEIGHT / 2);
|
||||
ctx.ticks(100);
|
||||
expect(ctx.player('alice').y).toBe(45);
|
||||
ctx.game.input('alice', 'stop');
|
||||
const y = ctx.player('alice').y;
|
||||
ctx.ticks(5);
|
||||
expect(ctx.player('alice').y).toBe(y);
|
||||
});
|
||||
|
||||
test('míček se od pálky odrazí', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
const face = PADDLE_MARGIN + PADDLE_WIDTH;
|
||||
ctx.game.setBallForTest(face + 20, FIELD_HEIGHT / 2, -400, 0);
|
||||
ctx.ticks(3);
|
||||
expect(ctx.last().ball.x).toBeGreaterThan(face);
|
||||
expect(ctx.player('bob').score).toBe(0);
|
||||
});
|
||||
|
||||
test('minutý míček je bod pro soupeře a hra končí na vítězné skóre', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
for (let i = 0; i < WIN_SCORE; i++) {
|
||||
// Míček u horního okraje, kam pálka uprostřed nedosáhne
|
||||
ctx.game.setBallForTest(60, 10, -600, 0);
|
||||
ctx.ticks(10);
|
||||
if (i < WIN_SCORE - 1) {
|
||||
expect(ctx.player('bob').score).toBe(i + 1);
|
||||
ctx.clock.advance(SERVE_PAUSE_MS);
|
||||
}
|
||||
}
|
||||
expect(ctx.last()).toMatchObject({ phase: 'finished', winner: 'bob' });
|
||||
});
|
||||
|
||||
test('myš: pálka rychle dojede k cílové výšce a šipka myš přebije', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.aim('alice', 0);
|
||||
ctx.ticks(5);
|
||||
expect(ctx.player('alice').y).toBe(PADDLE_HEIGHT / 2);
|
||||
ctx.game.input('alice', 'down');
|
||||
ctx.ticks(5);
|
||||
expect(ctx.player('alice').y).toBeGreaterThan(PADDLE_HEIGHT / 2);
|
||||
});
|
||||
|
||||
test('bota přidá jen zakladatel a lze ho odebrat', () => {
|
||||
const ctx = setup();
|
||||
ctx.game.join('alice');
|
||||
expect(() => ctx.game.addBot('bob', 'easy')).toThrow(GameError);
|
||||
expect(() => ctx.game.addBot('alice', 'superhard')).toThrow(GameError);
|
||||
ctx.game.addBot('alice', 'hard');
|
||||
expect(ctx.last().players.map(p => p.login)).toEqual(['alice', botLogin('hard')]);
|
||||
ctx.game.removeBot('alice');
|
||||
expect(ctx.last().players).toHaveLength(1);
|
||||
});
|
||||
|
||||
/** Odehraje zápas člověka, který myší dokonale sleduje míček, proti botovi. */
|
||||
function playAgainstBot(level: BotLevel, maxSeconds: number) {
|
||||
const ctx = setup(lcg(7));
|
||||
ctx.game.join('alice');
|
||||
ctx.game.addBot('alice', level);
|
||||
ctx.game.start('alice');
|
||||
ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||
for (let t = 0; t < maxSeconds * 1000 && ctx.last().phase === 'playing'; t += TICK_MS) {
|
||||
ctx.game.aim('alice', ctx.last().ball.y);
|
||||
ctx.ticks(1);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test('lehký bot proti dokonalému hráči prohraje', () => {
|
||||
const ctx = playAgainstBot('easy', 300);
|
||||
expect(ctx.last()).toMatchObject({ phase: 'finished', winner: 'alice' });
|
||||
});
|
||||
|
||||
test('nemožný bot neztratí ani bod', () => {
|
||||
const ctx = playAgainstBot('impossible', 120);
|
||||
expect(ctx.player('alice').score).toBe(0);
|
||||
expect(ctx.player(botLogin('impossible')).score).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { ReactionDuel, DuelState, PENALTY_MS, CLICK_WINDOW_MS, ROUND_PAUSE_MS, MIN_DELAY_MS, LOBBY_TIMEOUT_MS } from '../reactionDuel';
|
||||
import { GameError } from '../minigames';
|
||||
import { fakeClock } from './helpers/fakeClock';
|
||||
|
||||
function setup(minPlayers = 2) {
|
||||
const clock = fakeClock();
|
||||
const states: DuelState[] = [];
|
||||
const duel = new ReactionDuel(s => states.push(s), minPlayers, clock);
|
||||
return { duel, clock, last: () => states[states.length - 1] };
|
||||
}
|
||||
|
||||
/** Dohraje jedno kolo: počká na vyhlášení a nechá hráče kliknout s danými časy. */
|
||||
function playRound(ctx: ReturnType<typeof setup>, reactions: Record<string, number>) {
|
||||
ctx.clock.advance(MIN_DELAY_MS);
|
||||
expect(ctx.last().phase).toBe('go');
|
||||
const sorted = Object.entries(reactions).sort((a, b) => a[1] - b[1]);
|
||||
let elapsed = 0;
|
||||
for (const [login, ms] of sorted) {
|
||||
ctx.clock.advance(ms - elapsed);
|
||||
elapsed = ms;
|
||||
ctx.duel.click(login);
|
||||
}
|
||||
}
|
||||
|
||||
describe('reakční hra', () => {
|
||||
test('první hráč zakládá lobby a je hostitel', () => {
|
||||
const ctx = setup();
|
||||
ctx.duel.join('alice');
|
||||
ctx.duel.join('bob');
|
||||
expect(ctx.last()).toMatchObject({ phase: 'lobby', host: 'alice' });
|
||||
expect(ctx.last().players.map(p => p.login)).toEqual(['alice', 'bob']);
|
||||
});
|
||||
|
||||
test('hru může spustit jen hostitel a s dostatkem hráčů', () => {
|
||||
const ctx = setup();
|
||||
ctx.duel.join('alice');
|
||||
expect(() => ctx.duel.start('alice')).toThrow(GameError);
|
||||
ctx.duel.join('bob');
|
||||
expect(() => ctx.duel.start('bob')).toThrow(GameError);
|
||||
ctx.duel.start('alice');
|
||||
expect(ctx.last()).toMatchObject({ phase: 'waiting', round: 1 });
|
||||
});
|
||||
|
||||
test('do běžící hry se nelze připojit', () => {
|
||||
const ctx = setup();
|
||||
ctx.duel.join('alice');
|
||||
ctx.duel.join('bob');
|
||||
ctx.duel.start('alice');
|
||||
expect(() => ctx.duel.join('carol')).toThrow(GameError);
|
||||
});
|
||||
|
||||
test('vyhrává nejnižší součet časů, nejpomalejší je poslední', () => {
|
||||
const ctx = setup();
|
||||
ctx.duel.join('alice');
|
||||
ctx.duel.join('bob');
|
||||
ctx.duel.start('alice');
|
||||
playRound(ctx, { alice: 200, bob: 300 });
|
||||
expect(ctx.last()).toMatchObject({ phase: 'roundResult', round: 1 });
|
||||
ctx.clock.advance(ROUND_PAUSE_MS);
|
||||
playRound(ctx, { alice: 250, bob: 220 });
|
||||
ctx.clock.advance(ROUND_PAUSE_MS);
|
||||
playRound(ctx, { alice: 210, bob: 260 });
|
||||
|
||||
const state = ctx.last();
|
||||
expect(state.phase).toBe('finished');
|
||||
expect(state.players.find(p => p.login === 'alice')?.total).toBe(660);
|
||||
expect(state.players.find(p => p.login === 'bob')?.total).toBe(780);
|
||||
expect(state.winner).toBe('alice');
|
||||
expect(state.loser).toBe('bob');
|
||||
});
|
||||
|
||||
test('předčasný start a nekliknutí znamenají trestný čas', () => {
|
||||
const ctx = setup();
|
||||
ctx.duel.join('alice');
|
||||
ctx.duel.join('bob');
|
||||
ctx.duel.start('alice');
|
||||
ctx.duel.click('alice'); // před vyhlášením
|
||||
ctx.clock.advance(MIN_DELAY_MS);
|
||||
ctx.clock.advance(CLICK_WINDOW_MS); // bob neklikne
|
||||
const state = ctx.last();
|
||||
expect(state.phase).toBe('roundResult');
|
||||
expect(state.players.find(p => p.login === 'alice')?.rounds[0]).toEqual({ ms: null, falseStart: true });
|
||||
expect(state.players.find(p => p.login === 'bob')?.rounds[0]).toEqual({ ms: null });
|
||||
expect(state.players.every(p => p.total === PENALTY_MS)).toBe(true);
|
||||
});
|
||||
|
||||
test('odchodem hostitele se lobby ruší', () => {
|
||||
const ctx = setup();
|
||||
ctx.duel.join('alice');
|
||||
ctx.duel.join('bob');
|
||||
ctx.duel.leave('alice');
|
||||
expect(ctx.last()).toMatchObject({ phase: 'idle', players: [] });
|
||||
});
|
||||
|
||||
test('neodstartované lobby po čase vyprší', () => {
|
||||
const ctx = setup();
|
||||
ctx.duel.join('alice');
|
||||
ctx.clock.advance(LOBBY_TIMEOUT_MS);
|
||||
expect(ctx.last().phase).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { SnakeGame, SnakeState, TICK_MS, COUNTDOWN_SECONDS, WIDTH, HEIGHT } from '../snakeGame';
|
||||
import { GameError } from '../minigames';
|
||||
import { fakeClock } from './helpers/fakeClock';
|
||||
|
||||
/** Náhoda vracející zadané hodnoty a pak nuly (jídlo pak padá do rohu [0,0]). */
|
||||
const sequence = (...values: number[]) => () => values.length ? values.shift()! : 0;
|
||||
|
||||
/** Hodnoty náhody, které umístí jídlo na danou buňku. */
|
||||
const foodAt = (x: number, y: number) => [x / WIDTH, y / HEIGHT];
|
||||
|
||||
function setup(random?: () => number) {
|
||||
const clock = fakeClock(random);
|
||||
const states: SnakeState[] = [];
|
||||
const game = new SnakeGame(s => states.push(s), s => states.push(s), 1, clock);
|
||||
const last = () => states[states.length - 1];
|
||||
const player = (login: string) => last().players.find(p => p.login === login)!;
|
||||
const startGame = (...logins: string[]) => {
|
||||
logins.forEach(l => game.join(l));
|
||||
game.start(logins[0]);
|
||||
clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||
};
|
||||
const ticks = (n: number) => clock.advance(n * TICK_MS);
|
||||
return { game, clock, last, player, startGame, ticks };
|
||||
}
|
||||
|
||||
describe('had', () => {
|
||||
test('po odpočtu začne hra a had se posouvá o políčko za tick', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice');
|
||||
expect(ctx.last().phase).toBe('playing');
|
||||
expect(ctx.player('alice').body).toEqual([[5, 10], [4, 10], [3, 10]]);
|
||||
ctx.ticks(2);
|
||||
expect(ctx.player('alice').body).toEqual([[7, 10], [6, 10], [5, 10]]);
|
||||
});
|
||||
|
||||
test('zatáčí podle směru a obrat o 180° ignoruje', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice');
|
||||
ctx.game.turn('alice', 'left'); // obrat — ignoruje se
|
||||
ctx.ticks(1);
|
||||
expect(ctx.player('alice').body[0]).toEqual([6, 10]);
|
||||
ctx.game.turn('alice', 'up');
|
||||
ctx.ticks(1);
|
||||
expect(ctx.player('alice').body[0]).toEqual([6, 9]);
|
||||
});
|
||||
|
||||
test('nesmyslný směr se ignoruje', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice');
|
||||
ctx.game.turn('alice', 'diagonal');
|
||||
ctx.ticks(1);
|
||||
expect(ctx.player('alice').body[0]).toEqual([6, 10]);
|
||||
});
|
||||
|
||||
test('sněděné jídlo prodlouží hada a přidá bod', () => {
|
||||
const ctx = setup(sequence(...foodAt(7, 10)));
|
||||
ctx.startGame('alice');
|
||||
expect(ctx.last().food).toEqual([[7, 10]]);
|
||||
ctx.ticks(2);
|
||||
expect(ctx.player('alice').score).toBe(1);
|
||||
expect(ctx.player('alice').body).toHaveLength(4);
|
||||
expect(ctx.last().food).not.toContainEqual([7, 10]);
|
||||
});
|
||||
|
||||
test('náraz do zdi ukončí sólo hru', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice');
|
||||
ctx.ticks(WIDTH);
|
||||
expect(ctx.last().phase).toBe('finished');
|
||||
expect(ctx.player('alice')).toMatchObject({ alive: false, place: 1 });
|
||||
});
|
||||
|
||||
test('ve hře více hráčů vyhrává poslední přeživší', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob');
|
||||
// alice startuje v řádku 6, nahoru do zdi dorazí v 7. ticku
|
||||
ctx.game.turn('alice', 'up');
|
||||
ctx.ticks(7);
|
||||
expect(ctx.last().phase).toBe('finished');
|
||||
expect(ctx.player('bob')).toMatchObject({ alive: true, place: 1 });
|
||||
expect(ctx.player('alice')).toMatchObject({ alive: false, place: 2 });
|
||||
});
|
||||
|
||||
test('čelní srážka zabije oba hady', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob');
|
||||
// alice (5,6)→ doprava, bob (26,13)← doleva; po 7 tickách alice zatočí dolů
|
||||
// a po dalších 7 se obě hlavy potkají na (12,13)
|
||||
ctx.ticks(7);
|
||||
ctx.game.turn('alice', 'down');
|
||||
ctx.ticks(7);
|
||||
const state = ctx.last();
|
||||
expect(state.phase).toBe('finished');
|
||||
expect(state.players.every(p => !p.alive)).toBe(true);
|
||||
});
|
||||
|
||||
test('do běžící hry se nelze připojit', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice');
|
||||
expect(() => ctx.game.join('bob')).toThrow(GameError);
|
||||
});
|
||||
});
|
||||
@@ -1,451 +0,0 @@
|
||||
import {
|
||||
TankGame, TankState, MAPS, COLS, ROWS, TICK_MS, COUNTDOWN_SECONDS, RESPAWN_MS, DEATHMATCH_MS,
|
||||
LAST_MAN_LIVES, MAX_PLAYERS, TANK_RADIUS, ROCKET_AMMO, MINE_AMMO, MINE_ARM_MS, FIELD_WIDTH,
|
||||
} from '../tankGame';
|
||||
import { GameError } from '../minigames';
|
||||
import { fakeClock } from './helpers/fakeClock';
|
||||
|
||||
/** Deterministická pseudonáhoda pro boty. */
|
||||
function lcg(seed = 1) {
|
||||
let x = seed;
|
||||
return () => (x = (x * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
}
|
||||
|
||||
function setup(random?: () => number) {
|
||||
const clock = fakeClock(random);
|
||||
const states: TankState[] = [];
|
||||
const game = new TankGame(s => states.push(s), s => states.push(s), 2, clock);
|
||||
const last = () => states[states.length - 1];
|
||||
const player = (login: string) => last().players.find(p => p.login === login)!;
|
||||
/** Odrážení je ve výchozím stavu vypnuté, testy ho zapínají — sebezničení odraženou střelou je nejjednodušší zásah. */
|
||||
const startGame = (mode: 'deathmatch' | 'lastman' = 'deathmatch', all = ['alice', 'bob'], map = 'arena', teams = false, bounce = true) => {
|
||||
all.forEach(l => game.join(l));
|
||||
game.configure(all[0], { mode, mapId: map, teams, bounce });
|
||||
game.start(all[0]);
|
||||
clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||
};
|
||||
const ticks = (n: number) => clock.advance(n * TICK_MS);
|
||||
/** Jeden výstřel: stisk na jeden tick a puštění. */
|
||||
const shoot = (login: string) => {
|
||||
game.input(login, { fire: true });
|
||||
ticks(1);
|
||||
game.input(login, { fire: false });
|
||||
};
|
||||
return { game, clock, last, player, startGame, ticks, shoot };
|
||||
}
|
||||
|
||||
describe('tanky', () => {
|
||||
test('mapy mají správný rozměr, dost spawnů a všechna volná místa jsou propojená', () => {
|
||||
for (const map of MAPS) {
|
||||
expect(map.rows).toHaveLength(ROWS);
|
||||
map.rows.forEach(r => expect(r).toHaveLength(COLS));
|
||||
map.rows.forEach(r => expect(r).toMatch(/^[.#+~:%S]+$/));
|
||||
// Každý tým má na své půlce dost spawnů
|
||||
const leftSpawns = map.rows.join('').split('').filter((c, i) => c === 'S' && i % COLS < COLS / 2).length;
|
||||
expect(leftSpawns).toBeGreaterThanOrEqual(MAX_PLAYERS / 2);
|
||||
// Bedny se dají rozstřílet, takže se počítají jako průjezdné; zeď a voda ne
|
||||
const blocked = (c: string) => c === '#' || c === '~';
|
||||
const open: [number, number][] = [];
|
||||
map.rows.forEach((r, y) => [...r].forEach((c, x) => { if (!blocked(c)) open.push([x, y]); }));
|
||||
const seen = new Set([open[0].join()]);
|
||||
const queue = [open[0]];
|
||||
while (queue.length) {
|
||||
const [x, y] = queue.shift()!;
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = x + dx, ny = y + dy;
|
||||
if (map.rows[ny]?.[nx] === undefined || blocked(map.rows[ny][nx]) || seen.has(`${nx},${ny}`)) continue;
|
||||
seen.add(`${nx},${ny}`);
|
||||
queue.push([nx, ny]);
|
||||
}
|
||||
}
|
||||
expect(seen.size).toBe(open.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('hru spouští zakladatel a potřebuje alespoň dva tanky (boti se počítají)', () => {
|
||||
const ctx = setup();
|
||||
ctx.game.join('alice');
|
||||
expect(() => ctx.game.start('alice')).toThrow(GameError);
|
||||
expect(() => ctx.game.addBot('bob', 'easy')).toThrow(GameError);
|
||||
ctx.game.addBot('alice', 'easy');
|
||||
expect(ctx.last().players.map(p => p.login)).toContain('🤖 Bot 1 (Lehký)');
|
||||
ctx.game.start('alice');
|
||||
expect(ctx.last().phase).toBe('countdown');
|
||||
expect(() => ctx.game.join('bob')).toThrow(GameError);
|
||||
});
|
||||
|
||||
test('tank jede kupředu a zastaví se o okraj hřiště', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 100, 60, Math.PI);
|
||||
ctx.game.input('alice', { drive: 1 });
|
||||
ctx.ticks(30);
|
||||
expect(ctx.player('alice').x).toBe(TANK_RADIUS);
|
||||
expect(ctx.player('alice').y).toBe(60);
|
||||
});
|
||||
|
||||
test('nesmyslné ovládání se ořízne', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 300, 60, 0);
|
||||
ctx.game.input('alice', { drive: 5, turn: 'x' });
|
||||
ctx.ticks(10);
|
||||
expect(ctx.player('alice').x).toBeGreaterThan(300);
|
||||
expect(ctx.player('alice').angle).toBe(0);
|
||||
});
|
||||
|
||||
test('směrové ovládání jede rovnou zvoleným směrem', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 300, 300, 0);
|
||||
ctx.game.input('alice', { dx: 0, dy: -1 });
|
||||
ctx.ticks(10);
|
||||
const p = ctx.player('alice');
|
||||
expect(p.angle).toBeCloseTo(-Math.PI / 2, 2);
|
||||
expect(p.x).toBeCloseTo(300, 0);
|
||||
expect(p.y).toBeLessThan(300);
|
||||
});
|
||||
|
||||
test('směrové ovládání bez stisknutého směru tank zastaví a nechá natočený', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 300, 300, 0);
|
||||
ctx.game.input('alice', { dx: -1, dy: 0 });
|
||||
ctx.ticks(1);
|
||||
ctx.game.input('alice', { dx: 0, dy: 0 });
|
||||
const { x, angle } = ctx.player('alice');
|
||||
expect(x).toBeLessThan(300);
|
||||
ctx.ticks(5);
|
||||
expect(ctx.player('alice').angle).toBe(angle);
|
||||
expect(ctx.player('alice').x).toBe(x);
|
||||
});
|
||||
|
||||
test('zásah zničí soupeře, přidá bod a soupeř se po chvíli znovu objeví', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 100, 60, 0);
|
||||
ctx.game.placeForTest('bob', 300, 60, 0);
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(30);
|
||||
expect(ctx.player('alice')).toMatchObject({ score: 1, kills: 1 });
|
||||
expect(ctx.player('bob')).toMatchObject({ alive: false, deaths: 1 });
|
||||
ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1);
|
||||
expect(ctx.player('bob')).toMatchObject({ alive: true, protected: true });
|
||||
});
|
||||
|
||||
test('věž se natočí za myší nezávisle na korbě a střílí tím směrem', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 60, 60, 0);
|
||||
ctx.game.placeForTest('bob', 60, 250, 0);
|
||||
ctx.game.aimAt('alice', { x: 60, y: 250 });
|
||||
ctx.ticks(10);
|
||||
expect(ctx.player('alice').turret).toBeCloseTo(Math.PI / 2, 1);
|
||||
expect(ctx.player('alice').angle).toBe(0);
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(30);
|
||||
expect(ctx.player('bob')).toMatchObject({ alive: false });
|
||||
expect(ctx.player('alice').kills).toBe(1);
|
||||
// Bez myši se věž vrátí do směru jízdy
|
||||
ctx.game.aimAt('alice', { x: null });
|
||||
ctx.ticks(10);
|
||||
expect(ctx.player('alice').turret).toBeCloseTo(0, 1);
|
||||
});
|
||||
|
||||
test('ve výchozím stavu je režim poslední přeživší a střela na zdi zanikne', () => {
|
||||
const ctx = setup();
|
||||
ctx.game.join('alice');
|
||||
ctx.game.join('bob');
|
||||
expect(ctx.last()).toMatchObject({ mode: 'lastman', bounce: false });
|
||||
ctx.game.configure('alice', { mapId: 'arena' });
|
||||
ctx.game.start('alice');
|
||||
ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||
ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2);
|
||||
ctx.game.placeForTest('bob', 900, 500, 0);
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(20);
|
||||
expect(ctx.player('alice')).toMatchObject({ alive: true, lives: LAST_MAN_LIVES });
|
||||
expect(ctx.last().bullets).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('odražená vlastní střela zničí i střelce a stojí bod', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2);
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(20);
|
||||
expect(ctx.player('alice')).toMatchObject({ alive: false, score: -1, kills: 0 });
|
||||
});
|
||||
|
||||
test('střela rozbije bednu a zanikne', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 380, 20, 0);
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(10);
|
||||
expect(ctx.last().tiles[0][11]).toBe('.');
|
||||
expect(ctx.last().tiles[0][12]).toBe('+');
|
||||
expect(ctx.last().bullets).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('výstřel s hlavní v bedně ji rozbije rovnou', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 425, 20, 0);
|
||||
ctx.shoot('alice');
|
||||
expect(ctx.last().tiles[0][11]).toBe('.');
|
||||
expect(ctx.last().bullets).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('na přežití vypadne, kdo přijde o všechny životy, a hra skončí', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('lastman');
|
||||
expect(ctx.player('bob').lives).toBe(LAST_MAN_LIVES);
|
||||
for (let i = 0; i < LAST_MAN_LIVES; i++) {
|
||||
ctx.game.placeForTest('alice', 900, 300, 0);
|
||||
ctx.game.placeForTest('bob', 100, 60, -Math.PI / 2);
|
||||
ctx.shoot('bob');
|
||||
ctx.ticks(20);
|
||||
if (i < LAST_MAN_LIVES - 1) ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1);
|
||||
}
|
||||
expect(ctx.last().phase).toBe('finished');
|
||||
expect(ctx.player('alice').place).toBe(1);
|
||||
expect(ctx.player('bob')).toMatchObject({ place: 2, lives: 0 });
|
||||
});
|
||||
|
||||
test('hra na čas skončí po vypršení limitu', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.clock.advance(DEATHMATCH_MS + 100);
|
||||
expect(ctx.last().phase).toBe('finished');
|
||||
expect(ctx.last().players.every(p => p.place !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test('boti hrají mezi sebou a navzájem se ničí', () => {
|
||||
const ctx = setup(lcg(7));
|
||||
ctx.game.join('alice');
|
||||
ctx.game.configure('alice', { mode: 'deathmatch', mapId: 'maze' });
|
||||
ctx.game.addBot('alice', 'hard');
|
||||
ctx.game.addBot('alice', 'medium');
|
||||
ctx.game.start('alice');
|
||||
ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||
ctx.clock.advance(60 * 1000);
|
||||
const bots = ctx.last().players.filter(p => p.bot);
|
||||
expect(bots.reduce((sum, b) => sum + b.kills, 0)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tanky — bonusy', () => {
|
||||
/** Položí bonus pod tank a nechá ho sebrat. */
|
||||
const pickUp = (ctx: ReturnType<typeof setup>, login: string, type: Parameters<TankGame['addPickupForTest']>[2]) => {
|
||||
ctx.ticks(1); // aktuální poloha po placeForTest
|
||||
const p = ctx.player(login);
|
||||
ctx.game.addPickupForTest(p.x, p.y, type);
|
||||
ctx.ticks(1);
|
||||
};
|
||||
|
||||
test('raketa vybuchne při zásahu, zničí tank a zbyde po něm hořící vrak', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 100, 60, 0);
|
||||
ctx.game.placeForTest('bob', 300, 60, 0);
|
||||
pickUp(ctx, 'alice', 'rocket');
|
||||
expect(ctx.player('alice').rockets).toBe(ROCKET_AMMO);
|
||||
ctx.shoot('alice');
|
||||
expect(ctx.last().bullets[0]).toMatchObject({ rocket: true });
|
||||
ctx.ticks(30);
|
||||
expect(ctx.player('alice')).toMatchObject({ rockets: ROCKET_AMMO - 1, kills: 1, shots: 1 });
|
||||
expect(ctx.player('bob').alive).toBe(false);
|
||||
expect(ctx.last().wrecks).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('výbuch rakety rozbije i sousední bedny', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 380, 20, 0);
|
||||
ctx.game.placeForTest('bob', 900, 500, 0);
|
||||
pickUp(ctx, 'alice', 'rocket');
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(10);
|
||||
expect(ctx.last().tiles[0].slice(11, 13)).toBe('..');
|
||||
expect(ctx.last().explosions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('mina se aktivuje až po chvíli a vybuchne pod soupeřem', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 100, 60, 0);
|
||||
ctx.game.placeForTest('bob', 500, 300, 0);
|
||||
pickUp(ctx, 'alice', 'mine');
|
||||
ctx.game.dropMine('alice');
|
||||
ctx.ticks(1);
|
||||
expect(ctx.player('alice').mines).toBe(MINE_AMMO - 1);
|
||||
expect(ctx.last().mines[0]).toMatchObject({ x: 80, y: 60, armed: false });
|
||||
ctx.game.placeForTest('alice', 500, 500, 0);
|
||||
ctx.ticks(Math.ceil(MINE_ARM_MS / TICK_MS));
|
||||
ctx.game.placeForTest('bob', 200, 60, Math.PI);
|
||||
ctx.game.input('bob', { drive: 1 });
|
||||
ctx.ticks(40);
|
||||
expect(ctx.player('bob').alive).toBe(false);
|
||||
expect(ctx.player('alice').kills).toBe(1);
|
||||
expect(ctx.last().mines).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('štít pohltí jeden zásah', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 100, 60, 0);
|
||||
ctx.game.placeForTest('bob', 300, 60, 0);
|
||||
pickUp(ctx, 'bob', 'shield');
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(30);
|
||||
expect(ctx.player('bob')).toMatchObject({ alive: true, shield: false });
|
||||
});
|
||||
|
||||
test('turbo zrychlí jízdu', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 100, 60, 0);
|
||||
ctx.game.placeForTest('bob', 100, 300, 0);
|
||||
pickUp(ctx, 'alice', 'turbo');
|
||||
ctx.game.input('alice', { drive: 1 });
|
||||
ctx.game.input('bob', { drive: 1 });
|
||||
ctx.ticks(20);
|
||||
expect(ctx.player('alice').turbo).toBe(true);
|
||||
expect(ctx.player('alice').x - 100).toBeGreaterThan((ctx.player('bob').x - 100) * 1.4);
|
||||
});
|
||||
|
||||
test('naváděná střela se stočí k soupeři', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame();
|
||||
ctx.game.placeForTest('alice', 100, 60, 0);
|
||||
ctx.game.placeForTest('bob', 300, 130, 0);
|
||||
pickUp(ctx, 'alice', 'homing');
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(40);
|
||||
expect(ctx.player('bob').alive).toBe(false);
|
||||
});
|
||||
|
||||
test('život navíc se ve hře na přežití vrátí, ale nepřesáhne maximum', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('lastman');
|
||||
ctx.game.placeForTest('alice', 100, 60, -Math.PI / 2);
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(20);
|
||||
expect(ctx.player('alice').lives).toBe(LAST_MAN_LIVES - 1);
|
||||
ctx.ticks(Math.ceil(RESPAWN_MS / TICK_MS) + 1);
|
||||
pickUp(ctx, 'alice', 'heart');
|
||||
pickUp(ctx, 'alice', 'heart');
|
||||
expect(ctx.player('alice').lives).toBe(LAST_MAN_LIVES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tanky — terén', () => {
|
||||
test('voda tank zastaví, ale střela ji přeletí', () => {
|
||||
const ctx = setup();
|
||||
// Les: řádek 7 (y 280–320) je řeka, v prvním sloupci bez brodu
|
||||
ctx.startGame('deathmatch', ['alice', 'bob'], 'forest');
|
||||
ctx.game.placeForTest('alice', 20, 240, Math.PI / 2);
|
||||
ctx.game.placeForTest('bob', 20, 380, 0);
|
||||
ctx.game.input('alice', { drive: 1 });
|
||||
ctx.ticks(30);
|
||||
expect(ctx.player('alice').y).toBeLessThanOrEqual(280 - TANK_RADIUS);
|
||||
ctx.game.input('alice', { drive: 0 });
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(20);
|
||||
expect(ctx.player('bob').alive).toBe(false);
|
||||
});
|
||||
|
||||
test('porost zpomaluje jízdu', () => {
|
||||
const ctx = setup();
|
||||
// Les: v prvním řádku jsou sloupce 3–4 (x 120–200) porost, třetí řádek vlevo je volný
|
||||
ctx.startGame('deathmatch', ['alice', 'bob'], 'forest');
|
||||
ctx.game.placeForTest('alice', 130, 20, 0);
|
||||
ctx.game.placeForTest('bob', 20, 100, 0);
|
||||
ctx.game.input('alice', { drive: 1 });
|
||||
ctx.game.input('bob', { drive: 1 });
|
||||
ctx.ticks(10);
|
||||
expect(ctx.player('alice').x - 130).toBeLessThan((ctx.player('bob').x - 20) * 0.7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tanky — hardcore (tma)', () => {
|
||||
/** alice a těžký bot ve volném druhém řádku arény, bot natočený od alice. */
|
||||
function duel(hardcore: boolean, distance: number) {
|
||||
const ctx = setup();
|
||||
ctx.game.join('alice');
|
||||
ctx.game.configure('alice', { mode: 'deathmatch', mapId: 'arena', hardcore });
|
||||
ctx.game.addBot('alice', 'hard');
|
||||
ctx.game.start('alice');
|
||||
ctx.clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||
const bot = ctx.last().players.find(p => p.bot)!.login;
|
||||
ctx.game.placeForTest('alice', 100, 60, 0);
|
||||
ctx.game.placeForTest(bot, 100 + distance, 60, 0);
|
||||
ctx.ticks(30);
|
||||
return { ...ctx, bot };
|
||||
}
|
||||
|
||||
test('zakladatel ho zapne v lobby', () => {
|
||||
const ctx = setup();
|
||||
ctx.game.join('alice');
|
||||
ctx.game.configure('alice', { hardcore: true });
|
||||
expect(ctx.last().hardcore).toBe(true);
|
||||
});
|
||||
|
||||
test('bot ve tmě nevidí soupeře mimo světlo, bez tmy ano', () => {
|
||||
expect(duel(true, 600).last().bullets).toHaveLength(0);
|
||||
const lit = duel(false, 600);
|
||||
expect(lit.last().bullets.length > 0 || !lit.player('alice').alive).toBe(true);
|
||||
});
|
||||
|
||||
test('bot ve tmě vidí soupeře ve svém okolí', () => {
|
||||
const ctx = duel(true, 110);
|
||||
expect(ctx.last().bullets.length > 0 || !ctx.player('alice').alive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tanky — týmy', () => {
|
||||
const LOGINS = ['alice', 'bob', 'carol', 'dave'];
|
||||
|
||||
test('hráči se střídavě rozdělí do týmů a jde se přesunout', () => {
|
||||
const ctx = setup();
|
||||
LOGINS.forEach(l => ctx.game.join(l));
|
||||
ctx.game.configure('alice', { teams: true });
|
||||
expect(ctx.last().players.map(p => p.team)).toEqual([0, 1, 0, 1]);
|
||||
expect(() => ctx.game.switchTeam('bob', 'carol')).toThrow(GameError);
|
||||
ctx.game.switchTeam('bob');
|
||||
expect(ctx.player('bob').team).toBe(0);
|
||||
ctx.game.switchTeam('alice', 'bob');
|
||||
expect(ctx.player('bob').team).toBe(1);
|
||||
});
|
||||
|
||||
test('týmy začínají každý na své půlce a střela spoluhráčem proletí', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('deathmatch', LOGINS, 'arena', true);
|
||||
for (const p of ctx.last().players) {
|
||||
expect(p.x < FIELD_WIDTH / 2).toBe(p.team === 0);
|
||||
}
|
||||
// alice a carol jsou tým 0, bob tým 1
|
||||
ctx.game.placeForTest('alice', 100, 60, 0);
|
||||
ctx.game.placeForTest('carol', 200, 60, 0);
|
||||
ctx.game.placeForTest('bob', 300, 60, 0);
|
||||
ctx.shoot('alice');
|
||||
ctx.ticks(30);
|
||||
expect(ctx.player('carol').alive).toBe(true);
|
||||
expect(ctx.player('bob').alive).toBe(false);
|
||||
expect(ctx.last().teamScores).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
test('na čas vyhrává tým s víc body', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('deathmatch', LOGINS, 'arena', true);
|
||||
ctx.game.placeForTest('bob', 100, 60, 0);
|
||||
ctx.game.placeForTest('alice', 300, 60, 0);
|
||||
ctx.shoot('bob');
|
||||
ctx.clock.advance(DEATHMATCH_MS);
|
||||
const state = ctx.last();
|
||||
expect(state.phase).toBe('finished');
|
||||
expect(state.winnerTeam).toBe(1);
|
||||
expect(state.players.filter(p => p.place! <= 2).every(p => p.team === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
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';
|
||||
|
||||
const BOARD = '0'.repeat(BOARD_WIDTH * (BOARD_HEIGHT - 1)) + '1'.repeat(BOARD_WIDTH);
|
||||
|
||||
function setup(minPlayers = 1) {
|
||||
const clock = fakeClock();
|
||||
const all: TetrisState[] = [];
|
||||
const watched: TetrisState[] = [];
|
||||
const garbage: GarbageEvent[] = [];
|
||||
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 startWith = (settings: Partial<TetrisSettings>, ...logins: string[]) => {
|
||||
logins.forEach(l => game.join(l));
|
||||
game.configure(logins[0], settings);
|
||||
game.start(logins[0]);
|
||||
clock.advance(COUNTDOWN_SECONDS * 1000);
|
||||
};
|
||||
const startGame = (...logins: string[]) => startWith({}, ...logins);
|
||||
return { game, clock, all, watched, garbage, last, startGame, startWith };
|
||||
}
|
||||
|
||||
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', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob');
|
||||
expect(ctx.last()).toMatchObject({ phase: 'playing' });
|
||||
expect(typeof ctx.last().seed).toBe('number');
|
||||
});
|
||||
|
||||
test('desky se sledujícím posílají sloučeně', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob');
|
||||
ctx.game.update('alice', { board: BOARD, score: 100, lines: 1 });
|
||||
ctx.game.update('bob', { board: BOARD, score: 40, lines: 0 });
|
||||
expect(ctx.watched).toHaveLength(0);
|
||||
ctx.clock.advance(WATCH_FLUSH_MS);
|
||||
expect(ctx.watched).toHaveLength(1);
|
||||
expect(ctx.watched[0].players.find(p => p.login === 'alice')).toMatchObject({ board: BOARD, score: 100, lines: 1 });
|
||||
});
|
||||
|
||||
test('neplatná deska se zahodí', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice');
|
||||
ctx.game.update('alice', { board: 'x'.repeat(200), score: 5 });
|
||||
ctx.game.update('alice', { board: '0'.repeat(10) });
|
||||
ctx.clock.advance(WATCH_FLUSH_MS);
|
||||
expect(ctx.watched).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('smazání více řádků pošle odpad soupeři, jeden řádek nic', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob');
|
||||
ctx.game.cleared('alice', 1);
|
||||
expect(ctx.garbage).toHaveLength(0);
|
||||
ctx.game.cleared('alice', 4);
|
||||
expect(ctx.garbage).toEqual([{ target: 'bob', from: 'alice', lines: 4 }]);
|
||||
});
|
||||
|
||||
test('útoky jsou omezené frekvencí a velikostí', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob');
|
||||
ctx.game.cleared('alice', 2);
|
||||
ctx.game.cleared('alice', 2); // příliš brzy
|
||||
ctx.game.cleared('alice', 99); // nesmysl
|
||||
expect(ctx.garbage).toHaveLength(1);
|
||||
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
||||
ctx.game.cleared('alice', 3);
|
||||
expect(ctx.garbage.map(g => g.lines)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test('útok jde soupeři, který dostal nejméně odpadu', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob', 'carol', 'dave');
|
||||
ctx.game.cleared('alice', 4); // všichni 0 → první z nich (random = 0)
|
||||
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
||||
ctx.game.cleared('alice', 2);
|
||||
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
||||
ctx.game.cleared('alice', 3);
|
||||
ctx.game.cleared('bob', 2); // carol 1, dave 2, alice 0 → alice
|
||||
ctx.game.cleared('carol', 2); // alice 1, bob 4, dave 2 → alice
|
||||
expect(ctx.garbage.map(g => g.target)).toEqual(['bob', 'carol', 'dave', 'alice', 'alice']);
|
||||
});
|
||||
|
||||
test('odražený odpad zmenší útok, víc než tabulka poslat nejde', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob');
|
||||
ctx.game.cleared('alice', 4, 1);
|
||||
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
||||
ctx.game.cleared('alice', 2, 0); // vše odraženo → žádný útok
|
||||
ctx.game.cleared('alice', 2, 50);
|
||||
ctx.clock.advance(MIN_ATTACK_GAP_MS);
|
||||
ctx.game.cleared('alice', 3, -5);
|
||||
expect(ctx.garbage.map(g => g.lines)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
test('poslední hráč ve hře vyhrává', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob', 'carol');
|
||||
ctx.game.died('bob');
|
||||
expect(ctx.last().phase).toBe('playing');
|
||||
ctx.game.died('alice');
|
||||
const state = ctx.last();
|
||||
expect(state.phase).toBe('finished');
|
||||
const place = (l: string) => state.players.find(p => p.login === l)?.place;
|
||||
expect([place('carol'), place('alice'), place('bob')]).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('mrtvý hráč neútočí a nedostává odpad', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob', 'carol');
|
||||
ctx.game.died('bob');
|
||||
ctx.game.cleared('bob', 4);
|
||||
expect(ctx.garbage).toHaveLength(0);
|
||||
ctx.game.cleared('alice', 4);
|
||||
expect(ctx.garbage[0].target).toBe('carol');
|
||||
});
|
||||
|
||||
test('hráč, který přestal posílat desku, je vyřazen', () => {
|
||||
const ctx = setup();
|
||||
ctx.startGame('alice', 'bob');
|
||||
// alice hraje dál, bob zavřel okno
|
||||
for (let t = 0; t <= PLAYER_TIMEOUT_MS + 5000; t += 2000) {
|
||||
ctx.game.update('alice', { board: BOARD, score: 0, lines: 0 });
|
||||
ctx.clock.advance(2000);
|
||||
}
|
||||
expect(ctx.last().phase).toBe('finished');
|
||||
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', () => {
|
||||
const ctx = setup();
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,392 +0,0 @@
|
||||
import { Server } from "socket.io";
|
||||
import { Clock, defaultClock, GameError, gameHandler, minPlayersForEnv } 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<number, number> = { 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<string, InternalPlayer>();
|
||||
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 = 2,
|
||||
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<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) {
|
||||
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),
|
||||
// Sólo hra je jen Trénink v prohlížeči, společná hra by blokovala ostatní
|
||||
minPlayersForEnv(2),
|
||||
);
|
||||
|
||||
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)));
|
||||
});
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
import { DefaultEventsMap, Server } from "socket.io";
|
||||
import { createAdapter } from "@socket.io/redis-adapter";
|
||||
import { createClient } from "redis";
|
||||
import { registerReactionDuel } from "./reactionDuel";
|
||||
import { registerSnakeGame } from "./snakeGame";
|
||||
import { registerTetrisGame } from "./tetrisGame";
|
||||
import { registerPongGame } from "./pongGame";
|
||||
import { registerDrawingGame } from "./drawingGame";
|
||||
import { registerTankGame } from "./tankGame";
|
||||
import { registerLobbyChat } from "./minigames";
|
||||
|
||||
let io: Server<DefaultEventsMap, DefaultEventsMap, DefaultEventsMap, any>;
|
||||
let pubClient: ReturnType<typeof createClient>;
|
||||
@@ -35,13 +29,7 @@ export const initWebsocket = (server: any) => {
|
||||
// console.log(`Client disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
registerReactionDuel(io);
|
||||
registerSnakeGame(io);
|
||||
registerTetrisGame(io);
|
||||
registerPongGame(io);
|
||||
registerDrawingGame(io);
|
||||
registerTankGame(io);
|
||||
registerLobbyChat(io);
|
||||
return io;
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user