diff --git a/client/src/components/TetrisGame.tsx b/client/src/components/TetrisGame.tsx index 074b9e6..9d5fa2e 100644 --- a/client/src/components/TetrisGame.tsx +++ b/client/src/components/TetrisGame.tsx @@ -186,8 +186,8 @@ export default function TetrisGame() { if (practice || phase !== 'playing' || !isPlayer || state?.seed === undefined || sessionSeed.current === state.seed) return; sessionSeed.current = state.seed; sessionRef.current = new TetrisSession(state.seed, { - onLock: cleared => { - if (cleared >= 2) send('tetris:cleared', { count: cleared }); + onLock: (cleared, attack) => { + if (attack > 0) send('tetris:cleared', { count: cleared, sent: attack }); sendSnapshot(); setVersion(v => v + 1); }, @@ -335,7 +335,8 @@ export default function TetrisGame() { return (

Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz, - pošle náhodnému soupeři 1, 2 nebo 4 odpadní řádky. Vyhrává poslední hráč ve hře. + pošle 1, 2 nebo 4 odpadní řádky soupeři, který jich zatím dostal nejméně. Smazáním řádků + lze zároveň odrazit odpad, který na vás čeká. Vyhrává poslední hráč ve hře. Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe.

); diff --git a/client/src/games/tetrisEngine.ts b/client/src/games/tetrisEngine.ts index 707f4c3..3ef4817 100644 --- a/client/src/games/tetrisEngine.ts +++ b/client/src/games/tetrisEngine.ts @@ -36,6 +36,9 @@ const KICKS: Point[] = [[0, 0], [-1, 0], [1, 0], [0, -1], [-2, 0], [2, 0]]; /** Body za smazání 0–4 řádků naráz (násobí se úrovní). */ const LINE_SCORES = [0, 100, 300, 500, 800]; +/** Odpadní řádky za smazání 0–4 řádků naráz (musí odpovídat GARBAGE_FOR_LINES na serveru). */ +const ATTACK_FOR_LINES = [0, 0, 1, 2, 4]; + export type Piece = { type: number, rot: number, x: number, y: number }; /** Deterministický generátor (mulberry32) — stejný seed = stejné pořadí kostek u všech. */ @@ -53,8 +56,11 @@ function mulberry32(seed: number) { export const pieceCells = (p: Piece): Point[] => ROTATIONS[p.type][p.rot].map(([x, y]) => [p.x + x, p.y + y]); export type SessionCallbacks = { - /** Kostka dopadla; `cleared` = počet smazaných řádků. */ - onLock: (cleared: number) => void, + /** + * Kostka dopadla; `cleared` = počet smazaných řádků, + * `attack` = odpadní řádky pro soupeře po odražení vlastního čekajícího odpadu. + */ + onLock: (cleared: number, attack: number) => void, onDeath: () => void, }; @@ -128,7 +134,7 @@ export class TetrisSession { } } - /** Odpadní řádky se přidají až po dopadu aktuální kostky (jako v klasickém battle módu). */ + /** Odpadní řádky čekají a přidají se po dopadu kostky, která nic nesmaže (jako v klasickém battle módu). */ receiveGarbage(lines: number) { this.pendingGarbage += lines; } @@ -192,12 +198,19 @@ export class TetrisSession { this.active = null; const cleared = this.clearLines(); + let attack = 0; if (cleared > 0) { this.score += LINE_SCORES[cleared] * (this.level + 1); this.lines += cleared; + // Útok nejdřív odráží čekající odpad, soupeři jde jen zbytek + attack = ATTACK_FOR_LINES[cleared] ?? 0; + const cancelled = Math.min(attack, this.pendingGarbage); + this.pendingGarbage -= cancelled; + attack -= cancelled; } - this.callbacks.onLock(cleared); - if (this.pendingGarbage > 0) { + this.callbacks.onLock(cleared, attack); + // Dokud hráč maže řádky, zbylý odpad ještě čeká + if (cleared === 0 && this.pendingGarbage > 0) { const overflow = this.addGarbage(this.pendingGarbage); this.pendingGarbage = 0; if (overflow) { diff --git a/server/changelogs/2026-10-19.json b/server/changelogs/2026-10-19.json new file mode 100644 index 0000000..62e9575 --- /dev/null +++ b/server/changelogs/2026-10-19.json @@ -0,0 +1,4 @@ +[ + "Tetris: odpadní řádky se mezi soupeře rozdělují rovnoměrně místo náhodně", + "Tetris: smazáním řádků lze odrazit odpad, který na vás čeká" +] diff --git a/server/src/tests/tetrisGame.test.ts b/server/src/tests/tetrisGame.test.ts index 6811cce..fe11b50 100644 --- a/server/src/tests/tetrisGame.test.ts +++ b/server/src/tests/tetrisGame.test.ts @@ -68,6 +68,31 @@ describe('tetris', () => { 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'); diff --git a/server/src/tetrisGame.ts b/server/src/tetrisGame.ts index eb56c7b..14b902c 100644 --- a/server/src/tetrisGame.ts +++ b/server/src/tetrisGame.ts @@ -6,7 +6,7 @@ import { Clock, defaultClock, GameError, gameHandler } from "./minigames"; * * Každý hráč hraje vlastní desku v prohlížeči (kvůli odezvě ovládání), server koordinuje: * lobby a odpočet, společný seed pro pořadí kostek, přeposílání desek soupeřům, - * útoky (smazané řádky → odpadní řádky náhodnému soupeři) a vyřazování. + * útoky (smazané řádky → odpadní řádky soupeři, který zatím dostal nejméně odpadu) a vyřazování. * Vyhrává poslední hráč ve hře, sólo hra končí prohrou. * * Klientovi se věří (skóre, smazané řádky) — jde o kancelářskou hru; útoky jsou ale @@ -58,7 +58,13 @@ export type TetrisState = { export type GarbageEvent = { target: string, from: string, lines: number }; -type InternalPlayer = TetrisPlayer & { lastAttack: number, lastSeen: number, deathOrder?: number }; +type InternalPlayer = TetrisPlayer & { + lastAttack: number, + lastSeen: number, + deathOrder?: number, + /** Kolik odpadních řádků už hráč od soupeřů dostal — podle toho se volí cíl útoku. */ + garbageReceived: number, +}; const EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT); const BOARD_PATTERN = new RegExp(`^[0-8]{${BOARD_WIDTH * BOARD_HEIGHT}}$`); @@ -113,7 +119,7 @@ export class TetrisGame { 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 }); + this.players.set(login, { login, board: EMPTY_BOARD, score: 0, lines: 0, alive: true, lastAttack: 0, lastSeen: 0, garbageReceived: 0 }); } this.emitAll(); } @@ -160,17 +166,27 @@ export class TetrisGame { this.emitWatchers(); } - /** Hráč smazal řádky — případný útok jde náhodnému živému soupeři. */ - cleared(login: string, count: unknown) { + /** + * Hráč smazal řádky — případný útok jde živému soupeři, který zatím dostal nejméně odpadu + * (při shodě náhodně), aby se útoky rozkládaly rovnoměrně. + * + * @param sent kolik řádků po odražení vlastního čekajícího odpadu zbylo na útok (počítá klient); + * 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 garbage = GARBAGE_FOR_LINES[count as number] ?? 0; + 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 targets = [...this.players.values()].filter(t => t.alive && t.login !== login); - if (targets.length === 0) return; + const opponents = [...this.players.values()].filter(t => t.alive && t.login !== login); + if (opponents.length === 0) return; + const least = Math.min(...opponents.map(t => t.garbageReceived)); + const targets = opponents.filter(t => t.garbageReceived === least); const target = targets[Math.floor(this.clock.random() * targets.length)]; + target.garbageReceived += garbage; this.sendGarbage({ target: target.login, from: login, lines: garbage }); } @@ -288,7 +304,10 @@ export function registerTetrisGame(io: Server) { socket.on('tetris:leave', handle(login => game.leave(login))); 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) => game.cleared(login, (payload as { count?: unknown })?.count))); + 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))); }); }