refactor: комплексный рефакторинг + Isaac-like геймплей

Баги и гигиена (Wave 1):
- Player.addWeapon использует MODE_MELEE вместо литерала 1
- Фильтрация мёртвых врагов из room.enemies (раньше массив рос в долгих боях)
- Кап миньёнов босса (BOSS.maxMinions=4) — иначе комната могла не зачиститься
- aliveCount корректно считает живых в knockback-фазе
- equipSlot валидирует диапазон слота
- Магические числа range=70/spread=0.15 вынесены в WeaponDef (beamRange/spread)
- Регрессия cleared-flag: после фильтрации массива длина 0, но cleared должен стать true

Распил Game.ts 683→465 строк (Wave 2):
- systems/movement.ts: moveEntity(e, dx, dy, room) — убрал 5 копий коллизионного паттерна
- systems/projectiles.ts: applyWeaponProjectileStats, explodeBomb, projectileHitWall
- systems/ai.ts: runAI через диспетчер-таблицу (вместо if/else-if каскада)
- Переходы/этажи оставлены в Game.ts (тесно завязаны на cc/cr/player)

Тесты 33→72 (Wave 3):
- movement.test.ts, projectiles.test.ts, combat.test.ts, regressions.test.ts, items.test.ts
- Покрытие: ближний/дальний бой, огнемёт, бомба, лазер, splitter, сундук→пикап,
  переход комнат, фазы босса + кап миньёнов, фильтрация мёртвых, секретка

Isaac-like геймплей (Wave 4):
- Статы игрока: damageMul/fireRateMul/rangeMul/shotSpeedMul (мультипликативно
  поверх WeaponDef), effectiveDamage/Cooldown, отображение в HUD
- 8-направленный прицел: aimDir: Dir → aimVec: {x,y}, стрелки дают диагонали
- Пассивные предметы (items.ts, 6 штук): сундук дропает 50/50 оружие/предмет
- Новый тип врага splitter: при смерти распадается на двух fast
- Новый тип комнаты secret: +1 max HP один раз при первом входе
This commit is contained in:
2026-06-19 16:50:15 +03:00
parent 5d676b7d95
commit a72172df72
25 changed files with 1346 additions and 293 deletions
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect } from 'bun:test';
import { Game } from '../src/core/Game';
import { Rng } from '../src/core/rng';
import { Enemy } from '../src/core/entities/Enemy';
import { emptyInput, type InputState } from '../src/input/InputState';
import { DEFAULT_RULES } from '../src/core/rules';
import { MODE_RANGED, OX, OY, TILE, COLS } from '../src/config';
function input(patch: Partial<InputState> = {}): InputState {
return { ...emptyInput(), ...patch };
}
/** Ставит игру в normal-комнату с одним указанным врагом. */
function placeInCombatRoom(game: Game, e: Enemy): void {
for (const room of game.roomMap.rooms.values()) {
if (room.type !== 'normal') continue;
game.cc = room.c;
game.cr = room.r;
game.enterRoom('up');
room.enemies = [e];
room.cleared = false;
return;
}
throw new Error('normal-комната не найдена в карте');
}
describe('Combat (через Game.step)', () => {
it('снаряд ранит врага, на котором летит', () => {
const game = new Game(DEFAULT_RULES, new Rng(21));
// Враг прямо справа от игрока — стреляем вправо.
const enemy = new Enemy(game.player.x + 30, game.player.y, 'tank');
placeInCombatRoom(game, enemy);
const hp0 = enemy.hp;
game.step(input({ aimVec: { x: 1, y: 0 } }));
expect(enemy.hp).toBeLessThan(hp0);
});
it('огнемёт (staff) поджигает врага — урон продолжается после попадания', () => {
const game = new Game(DEFAULT_RULES, new Rng(22));
game.player.addWeapon('staff');
const enemy = new Enemy(game.player.x + 30, game.player.y, 'tank');
placeInCombatRoom(game, enemy);
game.step(input({ aimVec: { x: 1, y: 0 } })); // попадание = поджог
expect(enemy.burnTimer).toBeGreaterThan(0);
const hpAfterHit = enemy.hp;
// Несколько шагов без новых попаданий — урон от горения капает.
for (let i = 0; i < 30; i++) game.step(input());
expect(enemy.hp).toBeLessThan(hpAfterHit);
});
it('бомба даёт AoE-урон по нескольким врагам', () => {
const game = new Game(DEFAULT_RULES, new Rng(23));
game.player.addWeapon('bomb');
const near = new Enemy(game.player.x + 40, game.player.y, 'tank');
const far = new Enemy(game.player.x + 400, game.player.y, 'tank');
placeInCombatRoom(game, near);
game.curRoom.enemies.push(far);
const hpNear0 = near.hp;
const hpFar0 = far.hp;
game.step(input({ aimVec: { x: 1, y: 0 } }));
// Снаряд летит, ждём, пока он не исчезнет (долетит до стены и взорвётся).
for (let i = 0; i < 200; i++) game.step(input());
expect(near.hp).toBeLessThan(hpNear0);
expect(far.hp).toBe(hpFar0); // далеко — не задело
});
it('ближний бой (melee) наносит урон врагу в хитбоксе взмаха', () => {
const game = new Game(DEFAULT_RULES, new Rng(24));
// Дефолтный экипированный слот 0 = tears (ranged). Переключим на melee (слот 1).
game.player.equipped = 1;
game.player.mode = 1; // MODE_MELEE
// Враг прямо перед игроком (выше по Y).
const enemy = new Enemy(game.player.x, game.player.y - 30, 'tank');
placeInCombatRoom(game, enemy);
const hp0 = enemy.hp;
game.step(input({ aimVec: { x: 0, y: -1 } }));
expect(enemy.hp).toBeLessThan(hp0);
});
it(' лазер бьёт всех врагов в радиусе луча', () => {
const game = new Game(DEFAULT_RULES, new Rng(25));
game.player.addWeapon('laser');
const enemy = new Enemy(game.player.x + 50, game.player.y, 'tank');
placeInCombatRoom(game, enemy);
const hp0 = enemy.hp;
game.step(input({ aimVec: { x: 1, y: 0 } }));
// Луч стоит и жжёт несколько тиков.
for (let i = 0; i < 5; i++) game.step(input());
expect(enemy.hp).toBeLessThan(hp0);
});
});
describe('Combat — выстрел по сундуку', () => {
it('снаряд игрока ранит сундук в сокровищнице', () => {
const game = new Game(DEFAULT_RULES, new Rng(26));
// Ищем сокровищницу.
let treasureRoom: typeof game.curRoom | null = null;
for (const room of game.roomMap.rooms.values()) {
if (room.type === 'treasure') { treasureRoom = room; break; }
}
if (!treasureRoom) return; // на каком-то seed может не быть — тест пропустим мягко
game.cc = treasureRoom.c;
game.cr = treasureRoom.r;
game.enterRoom('up');
const chest = game.curRoom.chest!;
expect(chest).toBeDefined();
// Ставим игрока рядом с сундуком и стреляем вправо.
game.player.x = chest.x - 40;
game.player.y = chest.y;
const hp0 = chest.hp;
// Несколько выстрелов в сундук.
for (let i = 0; i < 10; i++) game.step(input({ aimVec: { x: 1, y: 0 } }));
expect(chest.hp).toBeLessThan(hp0);
});
});
describe('Splitter — распад при смерти', () => {
it('splitter умирает → спавнятся два fast', () => {
const game = new Game(DEFAULT_RULES, new Rng(27));
const e = new Enemy(game.player.x + 30, game.player.y, 'splitter');
placeInCombatRoom(game, e);
e.hp = 0; // убиваем напрямую
game.step(input());
// Ожидаем двух fast-детей (type !== splitter, должны быть 'fast').
const children = game.curRoom.enemies.filter((en) => en.type === 'fast');
expect(children.length).toBe(2);
// И сам splitter удалён (мёртвый).
expect(game.curRoom.enemies.some((en) => en.type === 'splitter')).toBe(false);
});
it('обычный враг при смерти НЕ плодит детей', () => {
const game = new Game(DEFAULT_RULES, new Rng(28));
const e = new Enemy(game.player.x + 30, game.player.y, 'normal');
placeInCombatRoom(game, e);
e.hp = 0;
game.step(input());
expect(game.curRoom.enemies.length).toBe(0);
});
});
+3 -3
View File
@@ -39,7 +39,7 @@ describe('Game', () => {
it('стрельба создаёт снаряд, который потом исчезает', () => {
const game = new Game(DEFAULT_RULES, new Rng(4));
game.step(input({ aimDir: 'right' }));
game.step(input({ aimVec: { x: 1, y: 0 } }));
expect(game.curRoom.tears.length).toBe(1);
// Снаряд летит вправо и со временем гаснет (стена/время жизни).
for (let i = 0; i < 200; i++) game.step(input());
@@ -158,7 +158,7 @@ describe('Game', () => {
const game = new Game(DEFAULT_RULES, new Rng(12));
game.player.addWeapon('boomerang');
game.step(input({ aimDir: 'right' }));
game.step(input({ aimVec: { x: 1, y: 0 } }));
expect(game.curRoom.tears[0].type).toBe('boomerang');
expect(game.curRoom.tears[0].damage).toBe(2);
@@ -171,7 +171,7 @@ describe('Game', () => {
const enemy = new Enemy(game.player.x + 7, game.player.y, 'tank');
game.curRoom.enemies = [enemy];
game.step(input({ aimDir: 'right' }));
game.step(input({ aimVec: { x: 1, y: 0 } }));
expect(enemy.burnTimer).toBeGreaterThan(0);
const hpAfterHit = enemy.hp;
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'bun:test';
import { Game } from '../src/core/Game';
import { Rng } from '../src/core/rng';
import { emptyInput, type InputState } from '../src/input/InputState';
import { DEFAULT_RULES } from '../src/core/rules';
import { ITEMS, applyItem, ALL_ITEM_IDS } from '../src/core/items';
import { NEUTRAL_STATS } from '../src/core/entities/Player';
import { Pickup } from '../src/core/entities/Pickup';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
function input(patch: Partial<InputState> = {}): InputState {
return { ...emptyInput(), ...patch };
}
describe('Предметы', () => {
it('applyItem модифицирует статы дельтой', () => {
const s = { ...NEUTRAL_STATS };
applyItem(s, ITEMS['cricket-head'], () => {});
expect(s.damageMul).toBeCloseTo(1.5, 5);
expect(s.fireRateMul).toBe(1); // не должно было измениться
applyItem(s, ITEMS['sad-onion'], () => {});
expect(s.fireRateMul).toBeCloseTo(1.35, 5);
// Стак того же предмета.
applyItem(s, ITEMS['cricket-head'], () => {});
expect(s.damageMul).toBeCloseTo(2.0, 5);
});
it('applyItem с maxHpBonus растит HP через колбэк', () => {
let hp = 6, maxHp = 6;
applyItem({ ...NEUTRAL_STATS }, ITEMS['blood-penny'], (bonus) => {
maxHp += bonus;
hp = Math.min(maxHp, hp + bonus);
});
expect(maxHp).toBe(8);
expect(hp).toBe(8);
});
it('ALL_ITEM_IDS содержит все предметы из ITEMS', () => {
expect(ALL_ITEM_IDS.length).toBe(Object.keys(ITEMS).length);
});
});
describe('Подбор предмета через Game', () => {
it('предмет лежит на полу, игрок его подбирает — статы меняются', () => {
const game = new Game(DEFAULT_RULES, new Rng(71));
// Найдём сокровищницу или просто normal — главное, чтобы у комнаты был pickup.
const room = game.curRoom;
const before = { damageMul: game.player.stats.damageMul };
room.pickup = Pickup.item(game.player.x, game.player.y, 'cricket-head');
game.step(input());
expect(room.pickup).toBeNull();
expect(game.player.stats.damageMul).toBeGreaterThan(before.damageMul);
});
it('предмет-лечение растит maxHp', () => {
const game = new Game(DEFAULT_RULES, new Rng(72));
const room = game.curRoom;
const maxHp0 = game.player.maxHp;
const hp0 = game.player.hp;
room.pickup = Pickup.item(game.player.x, game.player.y, 'blood-penny');
game.step(input());
expect(game.player.maxHp).toBe(maxHp0 + 2);
expect(game.player.hp).toBe(Math.min(maxHp0 + 2, hp0 + 2));
});
it('сундук может дропнуть как оружие, так и предмет', () => {
// На нескольких seed'ах должно выпадать хотя бы по разу каждого типа.
let weaponDrops = 0;
let itemDrops = 0;
for (let seed = 1; seed <= 50; seed++) {
const game = new Game(DEFAULT_RULES, new Rng(seed));
// Найдём сокровищницу.
let treasure = null;
for (const r of game.roomMap.rooms.values()) {
if (r.type === 'treasure') { treasure = r; break; }
}
if (!treasure) continue;
game.cc = treasure.c;
game.cr = treasure.r;
game.enterRoom('up');
const chest = game.curRoom.chest!;
chest.hp = 0;
for (let i = 0; i < 3; i++) game.step(input());
if (!game.curRoom.pickup) continue;
if (game.curRoom.pickup.kind === 'weapon') weaponDrops++;
else itemDrops++;
}
expect(weaponDrops).toBeGreaterThan(0);
expect(itemDrops).toBeGreaterThan(0);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'bun:test';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
import { Room } from '../src/core/world/Room';
import { moveEntity, type Movable } from '../src/core/systems/movement';
/** Минимальный movable, на котором удобно тестировать moveEntity. */
function box(x: number, y: number, size = 20): Movable & { x: number; y: number } {
const obj: Movable & { x: number; y: number } = {
x, y,
get box() {
return { x: this.x - size / 2, y: this.y - size / 2, w: size, h: size };
},
};
return obj;
}
/** Свежая зачищенная комната: двери открыты, по периметру стены. */
function makeRoom() {
const r = new Room(0, 0, 'spawn');
r.cleared = true;
r.rebuildTiles();
return r;
}
describe('moveEntity', () => {
it('свободно двигается по полу', () => {
const room = makeRoom();
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
const e = box(cx, cy);
moveEntity(e, 10, 5, room);
expect(e.x).toBe(cx + 10);
expect(e.y).toBe(cy + 5);
});
it('не проходит сквозь стену по X, но Y применяется (скольжение)', () => {
const room = makeRoom();
const x = OX + 3 * TILE;
const y = OY + (ROWS / 2) * TILE;
const e = box(x, y, 16);
// Большой рывок влево — хитбокс вылетит за col=0, isBlocked вернётся true.
moveEntity(e, -500, 10, room);
expect(e.x).toBe(x); // откатилось
expect(e.y).toBe(y + 10); // применилось
});
it('не проходит сквозь стену по Y, X применяется', () => {
const room = makeRoom();
const x = OX + (COLS / 2) * TILE;
const y = OY + 3 * TILE;
const e = box(x, y, 16);
moveEntity(e, 7, -500, room);
expect(e.x).toBe(x + 7);
expect(e.y).toBe(y);
});
it('ни X, ни Y не применяются, если в углу', () => {
const room = makeRoom();
const x = OX + 3 * TILE;
const y = OY + 3 * TILE;
const e = box(x, y, 16);
moveEntity(e, -200, -200, room);
expect(e.x).toBe(x);
expect(e.y).toBe(y);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'bun:test';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
import { Room } from '../src/core/world/Room';
import { Projectile } from '../src/core/entities/Projectile';
import { Enemy } from '../src/core/entities/Enemy';
import {
applyWeaponProjectileStats,
explodeBomb,
projectileHitWall,
} from '../src/core/systems/projectiles';
import { WEAPONS } from '../src/core/weapons';
import { NEUTRAL_STATS } from '../src/core/entities/Player';
const S = NEUTRAL_STATS;
function makeRoom() {
const r = new Room(0, 0, 'normal');
r.cleared = true;
r.rebuildTiles();
return r;
}
describe('applyWeaponProjectileStats', () => {
it('копирует урон и параметры горения из оружия', () => {
const t = new Projectile(0, 0, 1, 0, 'fireball');
applyWeaponProjectileStats(t, WEAPONS.staff, S);
expect(t.damage).toBe(WEAPONS.staff.damage);
expect(t.burnDamage).toBe(WEAPONS.staff.fireDmg);
expect(t.burnInterval).toBe(WEAPONS.staff.fireInterval);
expect(t.burnDuration).toBe(WEAPONS.staff.fireDuration);
});
it('для бомбы выставляет explosionRadius', () => {
const t = new Projectile(0, 0, 1, 0, 'bomb');
applyWeaponProjectileStats(t, WEAPONS.bomb, S);
expect(t.explosionRadius).toBe(WEAPONS.bomb.explosionRadius);
});
it('для обычной слезы горение остаётся 0', () => {
const t = new Projectile(0, 0, 1, 0, 'tear');
applyWeaponProjectileStats(t, WEAPONS.tears, S);
expect(t.burnDuration).toBe(0);
});
it('damageMul множит урон и горение', () => {
const t = new Projectile(0, 0, 1, 0, 'fireball');
applyWeaponProjectileStats(t, WEAPONS.staff, { ...S, damageMul: 2 });
expect(t.damage).toBe(WEAPONS.staff.damage * 2);
expect(t.burnDamage).toBe((WEAPONS.staff.fireDmg ?? 1) * 2);
});
it('shotSpeedMul множит скорость; rangeMul множит жизнь', () => {
const t = new Projectile(0, 0, 1, 0, 'tear');
const baseLife = t.life;
const baseSpeed = t.speed;
applyWeaponProjectileStats(t, WEAPONS.tears, { ...S, shotSpeedMul: 1.5, rangeMul: 2 });
expect(t.speed).toBeCloseTo(baseSpeed * 1.5, 5);
expect(t.life).toBe(baseLife * 2);
});
});
describe('projectileHitWall', () => {
it('ловит стену по краю', () => {
const room = makeRoom();
const t = new Projectile(OX + TILE - 1, OY + (ROWS / 2) * TILE, 0, 0, 'tear');
expect(projectileHitWall(t, room)).toBe(true);
});
it('пропускает центр пола', () => {
const room = makeRoom();
const t = new Projectile(OX + (COLS / 2) * TILE, OY + (ROWS / 2) * TILE, 0, 0, 'tear');
expect(projectileHitWall(t, room)).toBe(false);
});
});
describe('explodeBomb', () => {
it('не делает ничего для не-бомбы', () => {
const room = makeRoom();
const t = new Projectile(0, 0, 0, 0, 'tear');
const e = new Enemy(OX + 100, OY + 100, 'normal');
const hpBefore = e.hp;
room.enemies = [e];
explodeBomb(room, t);
expect(e.hp).toBe(hpBefore);
});
it('бомба бьёт врагов в радиусе и отбрасывает', () => {
const room = makeRoom();
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
const t = new Projectile(cx, cy, 0, 0, 'bomb');
t.explosionRadius = 80;
const e = new Enemy(cx + 10, cy + 10, 'normal');
const hpBefore = e.hp;
room.enemies = [e];
explodeBomb(room, t);
expect(e.hp).toBeLessThan(hpBefore);
expect(Math.abs(e.knx) + Math.abs(e.kny)).toBeGreaterThan(0);
});
it('бомба НЕ бьёт врагов за пределами радиуса', () => {
const room = makeRoom();
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
const t = new Projectile(cx, cy, 0, 0, 'bomb');
t.explosionRadius = 40;
const e = new Enemy(cx + 500, cy + 500, 'normal'); // далеко
const hpBefore = e.hp;
room.enemies = [e];
explodeBomb(room, t);
expect(e.hp).toBe(hpBefore);
});
});
+271
View File
@@ -0,0 +1,271 @@
import { describe, it, expect } from 'bun:test';
import { Game } from '../src/core/Game';
import { Rng } from '../src/core/rng';
import { Enemy } from '../src/core/entities/Enemy';
import { Projectile } from '../src/core/entities/Projectile';
import { emptyInput, type InputState } from '../src/input/InputState';
import { DEFAULT_RULES, PRESETS } from '../src/core/rules';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
function input(patch: Partial<InputState> = {}): InputState {
return { ...emptyInput(), ...patch };
}
/** Ставит игру в normal-комнату с указанными врагами. */
function placeInNormalRoom(game: Game, enemies: Enemy[]): void {
for (const room of game.roomMap.rooms.values()) {
if (room.type !== 'normal') continue;
game.cc = room.c;
game.cr = room.r;
game.enterRoom('up');
room.enemies = enemies;
room.cleared = false;
return;
}
throw new Error('normal-комната не найдена');
}
describe('Регрессии Wave 1', () => {
it('мёртвые враги удаляются из room.enemies (нет утечки)', () => {
const game = new Game(DEFAULT_RULES, new Rng(31));
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
// Три врага, по 1 hp — умрут почти сразу от попадания.
const e1 = new Enemy(cx - 50, cy, 'normal');
const e2 = new Enemy(cx + 50, cy, 'normal');
const e3 = new Enemy(cx, cy - 50, 'normal');
e1.hp = e2.hp = e3.hp = 1;
placeInNormalRoom(game, [e1, e2, e3]);
// Чтобы не полагаться на полёт снарядов — убиваем напрямую и прогоняем шаг.
e1.hp = 0; e2.hp = 0; e3.hp = 0;
game.step(input());
expect(game.curRoom.enemies.length).toBe(0); // фильтранулось, а не осталось 3 мёртвых
});
it('РЕГРЕССИЯ: после убийства всех врагов комната становится cleared (двери откроются)', () => {
const game = new Game(DEFAULT_RULES, new Rng(34));
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
const e = new Enemy(cx, cy, 'normal');
placeInNormalRoom(game, [e]);
expect(game.curRoom.cleared).toBe(false);
// Убиваем и прогоняем шаг — фильтр не должен помешать cleared стать true.
e.hp = 0;
game.step(input());
expect(game.curRoom.cleared).toBe(true);
expect(game.curRoom.enemies.length).toBe(0);
});
it('Player.addWeapon использует MODE_MELEE для melee-оружия', () => {
// Проверяем, что mode === MODE_MELEE (1), а не литерал 1 по ошибке.
const game = new Game(DEFAULT_RULES, new Rng(32));
game.player.addWeapon('axe'); // melee
expect(game.player.mode).toBe(1);
game.player.addWeapon('tears'); // ranged
expect(game.player.mode).toBe(0);
});
it('equipSlot молча игнорирует несуществующие слоты', () => {
const game = new Game(DEFAULT_RULES, new Rng(33));
const equippedBefore = game.player.equipped;
game.equipSlot(5); // не существует
expect(game.player.equipped).toBe(equippedBefore);
game.equipSlot(-1);
expect(game.player.equipped).toBe(equippedBefore);
});
it('WeaponDef.spread/beamRange вынесены из хардкода и читаются оружием', () => {
const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 999 };
const game = new Game(endless);
game.player.addWeapon('laser');
// Выстрел — луч должен оказаться на beamRange от игрока (70 по умолчанию).
const px = game.player.x;
game.step(input({ aimVec: { x: 1, y: 0 } }));
const beam = game.curRoom.tears.find((t) => t.type === 'beam');
expect(beam).toBeDefined();
expect(beam!.x).toBe(px + 70); // если не вынесли в WeaponDef — будет undefined → NaN
});
});
describe('Босс (milestone, этаж 5/10/15)', () => {
it('босс не плодит миньёнов сверх BOSS.maxMinions', () => {
const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 505 };
const game = new Game(endless);
// Домотать до 5-го этажа.
for (let i = 2; i <= 5; i++) {
const bossRoom = [...game.roomMap.rooms.values()].find((r) => r.type === 'boss')!;
bossRoom.cleared = true;
game.step(input());
}
expect(game.floor).toBe(5);
// Заходим в комнату босса.
const bossRoom = [...game.roomMap.rooms.values()].find((r) => r.type === 'boss')!;
game.cc = bossRoom.c;
game.cr = bossRoom.r;
game.enterRoom('up');
const boss = bossRoom.enemies.find((e) => e.type === 'boss')!;
expect(boss).toBeDefined();
// Опускаем HP до фазы 3 (< 33%), чтобы начался спавн миньёнов.
boss.hp = Math.floor(boss.maxHp * 0.1);
boss.spawnTimer = 0; // принудительно вызываем спавн в ближайший шаг
// Прогоняем много шагов, чтобы спавн многократно сработал.
for (let i = 0; i < 5000; i++) game.step(input());
const aliveEnemies = bossRoom.enemies.filter((e) => e.alive);
// Миньёны — все живые, кроме самого босса.
expect(aliveEnemies.length).toBeLessThanOrEqual(5); // босс + 4 миньёна максимум
});
});
describe('Переход между комнатами', () => {
it('игрок зачищает комнату и переходит в соседнюю', () => {
const game = new Game(DEFAULT_RULES, new Rng(41));
const spawnRoom = game.curRoom;
expect(spawnRoom.type).toBe('spawn');
// Берём первое доступное направление дверей у спавна.
const dir = (['up', 'down', 'left', 'right'] as const).find((d) => spawnRoom.doors[d]);
expect(dir).toBeDefined();
const cc0 = game.cc;
const cr0 = game.cr;
// Ставим игрока ОДИН ТАЙЛ ВНУТРЬ от двери (как делает enterRoom): тогда по
// ходу движения к двери он несколько шагов проведёт в крайнем тайле и
// checkTransition успеет сработать. (Старт прямо в door row был бы сразу
// вытолкнут за пределы комнаты.)
const center = doorNeighborCell(dir!);
game.player.place(center.x, center.y);
game.player.transCD = 0;
const move = {
up: { moveX: 0, moveY: -1 },
down: { moveX: 0, moveY: 1 },
left: { moveX: -1, moveY: 0 },
right: { moveX: 1, moveY: 0 },
}[dir!];
for (let i = 0; i < 80; i++) game.step(input(move));
expect(game.cc !== cc0 || game.cr !== cr0).toBe(true);
});
});
/** Тайл ВНУТРИ комнаты напротив двери (один шаг от двери). */
function doorNeighborCell(dir: 'up' | 'down' | 'left' | 'right'): { x: number; y: number } {
// Центр двери + один тайл внутрь.
const cx = 7.5;
const cy = 5.5;
switch (dir) {
case 'up': return { x: OX + cx * TILE, y: OY + 1.5 * TILE };
case 'down': return { x: OX + cx * TILE, y: OY + (ROWS - 1.5) * TILE };
case 'left': return { x: OX + 1.5 * TILE, y: OY + cy * TILE };
case 'right': return { x: OX + (COLS - 1.5) * TILE, y: OY + cy * TILE };
}
}
describe('Сундук → пикап → экипировка', () => {
it('сундук после уничтожения выпадает пикап оружия, который подбирается', () => {
const game = new Game(DEFAULT_RULES, new Rng(51));
// Найдём сокровищницу.
let treasure: typeof game.curRoom | null = null;
for (const room of game.roomMap.rooms.values()) {
if (room.type === 'treasure') { treasure = room; break; }
}
if (!treasure) return; // мягко пропускаем, если на этом seed нет
game.cc = treasure.c;
game.cr = treasure.r;
game.enterRoom('up');
const chest = game.curRoom.chest!;
const weaponBefore = game.player.weapons[game.player.equipped].id;
// Убиваем сундук напрямую и прогоняем несколько шагов: должен заспавнить пикап.
chest.hp = 0;
for (let i = 0; i < 5; i++) game.step(input());
expect(game.curRoom.pickup).not.toBeNull();
// Ставим игрока на пикап и прогоняем шаги — должен подобрать.
game.player.place(game.curRoom.pickup!.x, game.curRoom.pickup!.y);
for (let i = 0; i < 5; i++) game.step(input());
expect(game.curRoom.pickup).toBeNull();
expect(game.player.weapons[game.player.equipped].id).not.toBe(weaponBefore);
});
});
describe('Secret room (секретка)', () => {
it('спавнится на карте с шансом — среди 50 seed’ов хотя бы раз', () => {
let found = 0;
for (let seed = 1; seed <= 50; seed++) {
const game = new Game(DEFAULT_RULES, new Rng(seed));
for (const room of game.roomMap.rooms.values()) {
if (room.type === 'secret') { found++; break; }
}
}
expect(found).toBeGreaterThan(0);
});
it('при первом входе даёт +1 max HP; при повторном — НЕ даёт', () => {
// Найдём seed с секреткой.
let seed = 1;
let game = new Game(DEFAULT_RULES, new Rng(seed));
while (![...game.roomMap.rooms.values()].some(r => r.type === 'secret') && seed < 200) {
seed++;
game = new Game(DEFAULT_RULES, new Rng(seed));
}
if (seed >= 200) return; // мягко пропускаем
const secret = [...game.roomMap.rooms.values()].find(r => r.type === 'secret')!;
const maxHp0 = game.player.maxHp;
game.cc = secret.c; game.cr = secret.r;
game.enterRoom('up');
expect(game.player.maxHp).toBe(maxHp0 + 1);
// Выйдем и зайдём снова — бонуса быть не должно.
game.cc = 0; game.cr = 0; // возвращаемся в спавн
game.enterRoom('up');
const maxHp1 = game.player.maxHp;
game.cc = secret.c; game.cr = secret.r;
game.enterRoom('up');
expect(game.player.maxHp).toBe(maxHp1);
});
it('в секретке нет врагов (как в сокровищнице) — зачищается сразу', () => {
let seed = 1;
let game = new Game(DEFAULT_RULES, new Rng(seed));
while (![...game.roomMap.rooms.values()].some(r => r.type === 'secret') && seed < 200) {
seed++;
game = new Game(DEFAULT_RULES, new Rng(seed));
}
if (seed >= 200) return;
const secret = [...game.roomMap.rooms.values()].find(r => r.type === 'secret')!;
game.cc = secret.c; game.cr = secret.r;
game.enterRoom('up');
expect(game.curRoom.enemies.length).toBe(0);
expect(game.curRoom.cleared).toBe(true);
});
});
/** Тайл ВНУТРИ комнаты напротив двери (один шаг от двери). */
function doorNeighborCell(dir: 'up' | 'down' | 'left' | 'right'): { x: number; y: number } {
// Центр двери + один тайл внутрь.
const cx = 7.5;
const cy = 5.5;
switch (dir) {
case 'up': return { x: OX + cx * TILE, y: OY + 1.5 * TILE };
case 'down': return { x: OX + cx * TILE, y: OY + (ROWS - 1.5) * TILE };
case 'left': return { x: OX + 1.5 * TILE, y: OY + cy * TILE };
case 'right': return { x: OX + (COLS - 1.5) * TILE, y: OY + cy * TILE };
}
}