Баги и гигиена (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 один раз при первом входе
151 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|