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
+15 -2
View File
@@ -67,6 +67,19 @@ export class HudOverlay implements Renderer {
const floorLabel = game.rules.endless ? ` | Этаж ${game.floor}` : '';
ctx.fillText(`${game.rules.name}${floorLabel}`, 20, healthBottom + 14);
// Активные множители стат игрока (от предметов). Показываем только
// отличные от нейтральных — чтобы не засорять UI в начале забега.
const s = p.stats;
const bits: string[] = [];
if (s.damageMul !== 1) bits.push(`DMG ×${s.damageMul.toFixed(2)}`);
if (s.fireRateMul !== 1) bits.push(`RATE ×${s.fireRateMul.toFixed(2)}`);
if (s.rangeMul !== 1) bits.push(`RNG ×${s.rangeMul.toFixed(2)}`);
if (s.shotSpeedMul !== 1) bits.push(`SPD ×${s.shotSpeedMul.toFixed(2)}`);
if (bits.length) {
ctx.fillStyle = '#9a7'; ctx.font = '10px monospace';
ctx.fillText(bits.join(' '), 20, healthBottom + 28);
}
// Индикатор оружия.
const my = CH - 46;
const w = p.currentWeapon;
@@ -92,7 +105,7 @@ export class HudOverlay implements Renderer {
// Подпись типа комнаты.
if (room.visited) {
const label = { spawn: 'СТАРТ', normal: '', treasure: 'СОКРОВИЩЕ', boss: 'БОСС' }[room.type];
const label = { spawn: 'СТАРТ', normal: '', treasure: 'СОКРОВИЩЕ', boss: 'БОСС', secret: 'СЕКРЕТ' }[room.type];
if (label) {
ctx.textAlign = 'right'; ctx.fillStyle = '#555'; ctx.font = '11px monospace';
ctx.fillText(label, CW - 20, OY + RH + 30);
@@ -145,7 +158,7 @@ export class HudOverlay implements Renderer {
let color = '#141414';
if (room.visited) {
color = { spawn: '#2a5a2a', boss: '#5a1a1a', treasure: '#5a5a1a', normal: '#555' }[room.type];
color = { spawn: '#2a5a2a', boss: '#5a1a1a', treasure: '#5a5a1a', secret: '#1a3a5a', normal: '#555' }[room.type];
}
ctx.fillStyle = color; ctx.fillRect(x, y, cell, cell);
+15 -9
View File
@@ -8,7 +8,6 @@ import type { Game } from '../core/Game';
import type { Room } from '../core/world/Room';
import type { Enemy } from '../core/entities/Enemy';
import type { Projectile } from '../core/entities/Projectile';
import type { WeaponId } from '../core/weapons';
import type { Renderer } from './Renderer';
import { DEFAULT_THEME, type Theme } from './theme';
import { Assets, type SpriteKey } from './assets';
@@ -55,6 +54,7 @@ export class ThreeRenderer implements Renderer {
private readonly enemyMatKey: Record<Enemy['type'], SpriteKey> = {
normal: 'enemy-normal', fast: 'enemy-fast', boss: 'enemy-boss',
charger: 'enemy-charger', tank: 'enemy-tank', shooter: 'enemy-shooter',
splitter: 'enemy-fast', // пока используем визуал fast, пока нет отдельной текстуры
};
// Группа статичной геометрии комнаты (пол + стены + двери).
@@ -73,7 +73,7 @@ export class ThreeRenderer implements Renderer {
private chestMesh: THREE.Mesh | null = null;
private pickupMesh: THREE.Mesh | null = null;
private currentPickupWeapon: WeaponId | null = null;
private currentPickupWeapon: string | null = null;
constructor(canvas: HTMLCanvasElement, assetBasePathOrTheme: string | Theme = 'assets', theme: Theme = DEFAULT_THEME) {
const assetBasePath = typeof assetBasePathOrTheme === 'string' ? assetBasePathOrTheme : 'assets';
@@ -347,24 +347,30 @@ export class ThreeRenderer implements Renderer {
private syncPickup(room: Room, visualStep: number): void {
if (room.pickup) {
const wid = room.pickup.weaponId;
if (!this.pickupMesh || this.currentPickupWeapon !== wid) {
// Уникальный ключ для кэша меша: для оружия — его id, для предмета — префикс.
const pk = room.pickup;
const key = pk.kind === 'weapon' && pk.weaponId
? `w:${pk.weaponId}`
: `i:${pk.itemId ?? '?'}`;
if (!this.pickupMesh || this.currentPickupWeapon !== key) {
if (this.pickupMesh) {
this.scene.remove(this.pickupMesh);
(this.pickupMesh.material as THREE.Material).dispose();
}
const tex = pk.kind === 'weapon' && pk.weaponId
? this.assets.weaponIcon(pk.weaponId)
: this.assets.sprite('pickup');
const mat = new THREE.MeshBasicMaterial({
map: this.assets.weaponIcon(wid), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide,
map: tex, transparent: true, alphaTest: 0.3, side: THREE.DoubleSide,
});
this.pickupMesh = new THREE.Mesh(this.vGeo, mat);
this.scene.add(this.pickupMesh);
this.currentPickupWeapon = wid;
this.currentPickupWeapon = key;
}
const p = room.pickup;
const w = p.w * 1.6;
const w = pk.w * 1.6;
const h = w * (48 / 48);
this.pickupMesh.scale.set(w, h, 1);
this.pickupMesh.position.set(p.x, h / 2 + Math.sin(visualStep / 12) * 3, p.y);
this.pickupMesh.position.set(pk.x, h / 2 + Math.sin(visualStep / 12) * 3, pk.y);
} else if (this.pickupMesh) {
this.scene.remove(this.pickupMesh);
(this.pickupMesh.material as THREE.Material).dispose();