fix. férovější výběr cíle u Tetrisu, odrážení odpadu
This commit is contained in:
5 files changed
+79
-17
No files matched your search
@@ -186,8 +186,8 @@ export default function TetrisGame() {
|
|||||||
if (practice || phase !== 'playing' || !isPlayer || state?.seed === undefined || sessionSeed.current === state.seed) return;
|
if (practice || phase !== 'playing' || !isPlayer || state?.seed === undefined || sessionSeed.current === state.seed) return;
|
||||||
sessionSeed.current = state.seed;
|
sessionSeed.current = state.seed;
|
||||||
sessionRef.current = new TetrisSession(state.seed, {
|
sessionRef.current = new TetrisSession(state.seed, {
|
||||||
onLock: cleared => {
|
onLock: (cleared, attack) => {
|
||||||
if (cleared >= 2) send('tetris:cleared', { count: cleared });
|
if (attack > 0) send('tetris:cleared', { count: cleared, sent: attack });
|
||||||
sendSnapshot();
|
sendSnapshot();
|
||||||
setVersion(v => v + 1);
|
setVersion(v => v + 1);
|
||||||
},
|
},
|
||||||
@@ -335,7 +335,8 @@ export default function TetrisGame() {
|
|||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz,
|
Tetris pro více hráčů. Všichni dostávají stejné kostky; kdo smaže 2, 3 nebo 4 řádky naráz,
|
||||||
pošle 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.
|
Hrát se dá i sólo, nebo si kdykoli zatrénovat jen pro sebe.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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í). */
|
/** Body za smazání 0–4 řádků naráz (násobí se úrovní). */
|
||||||
const LINE_SCORES = [0, 100, 300, 500, 800];
|
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 };
|
export type Piece = { type: number, rot: number, x: number, y: number };
|
||||||
|
|
||||||
/** Deterministický generátor (mulberry32) — stejný seed = stejné pořadí kostek u všech. */
|
/** 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 const pieceCells = (p: Piece): Point[] => ROTATIONS[p.type][p.rot].map(([x, y]) => [p.x + x, p.y + y]);
|
||||||
|
|
||||||
export type SessionCallbacks = {
|
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,
|
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) {
|
receiveGarbage(lines: number) {
|
||||||
this.pendingGarbage += lines;
|
this.pendingGarbage += lines;
|
||||||
}
|
}
|
||||||
@@ -192,12 +198,19 @@ export class TetrisSession {
|
|||||||
this.active = null;
|
this.active = null;
|
||||||
|
|
||||||
const cleared = this.clearLines();
|
const cleared = this.clearLines();
|
||||||
|
let attack = 0;
|
||||||
if (cleared > 0) {
|
if (cleared > 0) {
|
||||||
this.score += LINE_SCORES[cleared] * (this.level + 1);
|
this.score += LINE_SCORES[cleared] * (this.level + 1);
|
||||||
this.lines += cleared;
|
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);
|
this.callbacks.onLock(cleared, attack);
|
||||||
if (this.pendingGarbage > 0) {
|
// Dokud hráč maže řádky, zbylý odpad ještě čeká
|
||||||
|
if (cleared === 0 && this.pendingGarbage > 0) {
|
||||||
const overflow = this.addGarbage(this.pendingGarbage);
|
const overflow = this.addGarbage(this.pendingGarbage);
|
||||||
this.pendingGarbage = 0;
|
this.pendingGarbage = 0;
|
||||||
if (overflow) {
|
if (overflow) {
|
||||||
|
|||||||
@@ -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á"
|
||||||
|
]
|
||||||
@@ -68,6 +68,31 @@ describe('tetris', () => {
|
|||||||
expect(ctx.garbage.map(g => g.lines)).toEqual([1, 2]);
|
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á', () => {
|
test('poslední hráč ve hře vyhrává', () => {
|
||||||
const ctx = setup();
|
const ctx = setup();
|
||||||
ctx.startGame('alice', 'bob', 'carol');
|
ctx.startGame('alice', 'bob', 'carol');
|
||||||
|
|||||||
@@ -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:
|
* Každý hráč hraje vlastní desku v prohlížeči (kvůli odezvě ovládání), server koordinuje:
|
||||||
* lobby a odpočet, společný seed pro pořadí kostek, přeposílání desek soupeřům,
|
* lobby a odpočet, společný seed pro pořadí kostek, přeposílání desek soupeřům,
|
||||||
* útoky (smazané řádky → odpadní řádky 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.
|
* 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
|
* 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 };
|
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 EMPTY_BOARD = '0'.repeat(BOARD_WIDTH * BOARD_HEIGHT);
|
||||||
const BOARD_PATTERN = new RegExp(`^[0-8]{${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) {
|
if (this.players.size >= MAX_PLAYERS) {
|
||||||
throw new GameError('Lobby je plné.');
|
throw new GameError('Lobby je plné.');
|
||||||
}
|
}
|
||||||
this.players.set(login, { login, board: EMPTY_BOARD, score: 0, lines: 0, alive: true, lastAttack: 0, lastSeen: 0 });
|
this.players.set(login, { login, board: EMPTY_BOARD, score: 0, lines: 0, alive: true, lastAttack: 0, lastSeen: 0, garbageReceived: 0 });
|
||||||
}
|
}
|
||||||
this.emitAll();
|
this.emitAll();
|
||||||
}
|
}
|
||||||
@@ -160,17 +166,27 @@ export class TetrisGame {
|
|||||||
this.emitWatchers();
|
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);
|
const p = this.players.get(login);
|
||||||
if (!p || !p.alive || this.phase !== 'playing' || !Number.isInteger(count)) return;
|
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();
|
const now = this.clock.now();
|
||||||
if (garbage === 0 || now - p.lastAttack < MIN_ATTACK_GAP_MS) return;
|
if (garbage === 0 || now - p.lastAttack < MIN_ATTACK_GAP_MS) return;
|
||||||
p.lastAttack = now;
|
p.lastAttack = now;
|
||||||
const targets = [...this.players.values()].filter(t => t.alive && t.login !== login);
|
const opponents = [...this.players.values()].filter(t => t.alive && t.login !== login);
|
||||||
if (targets.length === 0) return;
|
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)];
|
const target = targets[Math.floor(this.clock.random() * targets.length)];
|
||||||
|
target.garbageReceived += garbage;
|
||||||
this.sendGarbage({ target: target.login, from: login, lines: 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:leave', handle(login => game.leave(login)));
|
||||||
socket.on('tetris:start', handle(login => game.start(login)));
|
socket.on('tetris:start', handle(login => game.start(login)));
|
||||||
socket.on('tetris:update', handle((login, payload) => game.update(login, payload)));
|
socket.on('tetris:update', handle((login, payload) => game.update(login, payload)));
|
||||||
socket.on('tetris:cleared', handle((login, payload) => 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)));
|
socket.on('tetris:died', handle(login => game.died(login)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user