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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user