Files
Binding-Fignyaac/tests/combat.test.ts
T
mayatnikov a72172df72 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 один раз при первом входе
2026-06-19 16:50:15 +03:00

151 lines
6.2 KiB
TypeScript

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);
});
});