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