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