From a72172df72dba21dadc8e363e8119cf4d03f4f6e Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Fri, 19 Jun 2026 16:50:15 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BB?= =?UTF-8?q?=D0=B5=D0=BA=D1=81=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20+=20Isaac-like=20?= =?UTF-8?q?=D0=B3=D0=B5=D0=B9=D0=BC=D0=BF=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Баги и гигиена (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 один раз при первом входе --- docs/ARCHITECTURE.md | 5 +- src/config.ts | 10 + src/core/Game.ts | 342 ++++++++++-------------------- src/core/entities/Pickup.ts | 48 +++++ src/core/entities/Player.ts | 52 ++++- src/core/entities/WeaponPickup.ts | 20 -- src/core/items.ts | 78 +++++++ src/core/systems/ai.ts | 171 +++++++++++++++ src/core/systems/movement.ts | 26 +++ src/core/systems/projectiles.ts | 65 ++++++ src/core/systems/spawner.ts | 7 +- src/core/types.ts | 4 +- src/core/weapons.ts | 10 +- src/core/world/Room.ts | 4 +- src/core/world/RoomMap.ts | 2 + src/input/InputState.ts | 34 ++- src/input/KeyboardController.ts | 17 +- src/render/HudOverlay.ts | 17 +- src/render/ThreeRenderer.ts | 24 ++- tests/combat.test.ts | 150 +++++++++++++ tests/game.test.ts | 6 +- tests/items.test.ts | 97 +++++++++ tests/movement.test.ts | 66 ++++++ tests/projectiles.test.ts | 113 ++++++++++ tests/regressions.test.ts | 271 +++++++++++++++++++++++ 25 files changed, 1346 insertions(+), 293 deletions(-) create mode 100644 src/core/entities/Pickup.ts delete mode 100644 src/core/entities/WeaponPickup.ts create mode 100644 src/core/items.ts create mode 100644 src/core/systems/ai.ts create mode 100644 src/core/systems/movement.ts create mode 100644 src/core/systems/projectiles.ts create mode 100644 tests/combat.test.ts create mode 100644 tests/items.test.ts create mode 100644 tests/movement.test.ts create mode 100644 tests/projectiles.test.ts create mode 100644 tests/regressions.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e5383b7..bf689ac 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -67,10 +67,11 @@ ## Ввод (input/) -`InputState` — снимок намерений: оси движения, направление прицела, флаги +`InputState` — снимок намерений: оси движения, вектор прицела, флаги удержания и однократные «edge»-действия. Делится на: -- **удерживаемые** (`moveX/Y`, `aimDir`, `attackHeld`) — читаются каждый шаг; +- **удерживаемые** (`moveX/Y`, `aimVec`, `attackHeld`) — читаются каждый шаг; + `aimVec` — вектор из стрелок, поддерживает 8 направлений (вкл. диагонали); - **однократные** (`toggleWeapon`, `restart`) — срабатывают один раз на нажатие; поэтому они обрабатываются в `consumeActions()` раз в кадр, а не в `step()`. diff --git a/src/config.ts b/src/config.ts index bbdfff6..97b1e11 100644 --- a/src/config.ts +++ b/src/config.ts @@ -127,6 +127,7 @@ export const ENEMY_STATS = { charger: { size: 32, hp: 5, speed: 1.6, damage: 1 }, tank: { size: 44, hp: 10, speed: 0.6, damage: 2 }, shooter: { size: 28, hp: 3, speed: 1.0, damage: 1 }, + splitter: { size: 34, hp: 4, speed: 1.1, damage: 1 }, } as const; export const ENEMY = { @@ -149,6 +150,7 @@ export const SPAWN = { maxPlacementTries: 100, treasureChance: 0.12, // шанс комнаты-сокровищницы bossChance: 0.2, // шанс назначить комнату боссом + secretChance: 0.08, // шанс секретной комнаты (+1 max HP один раз при входе) }; // ───────────────────────────────────────────────────────────── @@ -170,3 +172,11 @@ export const FLOOR_SCALING = { bossHpMulPerFloor: 0.2, // + множитель HP босса на этаж fastChancePerFloor: 0.03, // + доля быстрых врагов на этаж }; + +// ───────────────────────────────────────────────────────────── +// Боссы (мультифазные, milestone-этажи) +// ───────────────────────────────────────────────────────────── +export const BOSS = { + maxMinions: 4, // верхний лимит живых миньёнов одного босса — иначе комната может не зачиститься + minionInterval: 120, // шагов между попытками спавна миньёнов (фаза 3) +}; diff --git a/src/core/Game.ts b/src/core/Game.ts index b4003fc..97a5760 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -1,5 +1,5 @@ import { - DIR, DOOR, OX, OY, TILE, COLS, ROWS, T_WALL, + DIR, DOOR, OX, OY, TILE, COLS, ROWS, MODE_RANGED, MODE_MELEE, PLAYER, ENEMY, MELEE, PROJECTILE, } from '../config'; import type { Dir } from './types'; @@ -12,12 +12,17 @@ import { MeleeSwing } from './entities/MeleeSwing'; import { RoomMap } from './world/RoomMap'; import type { Room } from './world/Room'; import { collidesWall } from './systems/collision'; +import { moveEntity } from './systems/movement'; +import { runAI, spawnSplitterChildren, type AIContext } from './systems/ai'; +import { + applyWeaponProjectileStats, explodeBomb, projectileHitWall, +} from './systems/projectiles'; import { spawnEnemies, spawnChest, pickChestWeapon } from './systems/spawner'; -import { WeaponPickup } from './entities/WeaponPickup'; -import type { WeaponDef } from './weapons'; +import { Pickup } from './entities/Pickup'; +import { ITEMS, applyItem, ALL_ITEM_IDS, type ItemId } from './items'; import { DEFAULT_RULES, scaleRulesForFloor, type LevelRules } from './rules'; import type { InputState } from '../input/InputState'; -import { pressingDir } from '../input/InputState'; +import { pressingDir, cardinalFromVec } from '../input/InputState'; /** * Game — «мозг» игры. Полностью независим от рендера и DOM: ничего не @@ -81,10 +86,10 @@ export class Game { } } - /** Выбрать слот 0 или 1 (из main.ts при открытом инвентаре). */ + /** Выбрать слот (из main.ts при открытом инвентаре). Молча игнорирует несуществующие. */ equipSlot(slot: number): void { - if (slot !== 0 && slot !== 1) return; - this.player.equipped = slot; + if (slot < 0 || slot >= this.player.weapons.length) return; + this.player.equipped = slot as 0 | 1; this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE; this.inventoryOpen = false; } @@ -122,8 +127,11 @@ export class Game { const aliveCount = this.updateEnemies(room, p); if (this.gameOver) return; - // Комната зачищена: открываем двери. - if (room.enemies.length > 0 && aliveCount === 0 && !room.cleared) { + // Комната зачищена: все враги мертвы (aliveCount === 0 после фильтра + // означает, что ни живых, ни свежеспавненных не осталось). Проверку + // через room.enemies.length использовать нельзя — после фильтрации длина + // уже 0; считаем по живым из updateEnemies. + if (aliveCount === 0 && !room.cleared && room.type !== 'spawn' && room.type !== 'treasure' && room.type !== 'secret') { room.cleared = true; room.rebuildTiles(); } @@ -156,6 +164,7 @@ export class Game { /** Расставляет игрока внутри текущей комнаты у двери fromDir и (при нужде) спавнит врагов. */ enterRoom(fromDir: Dir): void { const room = this.curRoom; + const wasVisited = room.visited; // ловим «первый вход» до установки флага room.visited = true; const d = DOOR[fromDir]; @@ -177,8 +186,12 @@ export class Game { if (room.type === 'treasure' && !room.chest) { room.chest = spawnChest(room, this.rng); } - // Если врагов нет (напр. сокровищница) — зачищать нечего, открываем сразу, - // иначе двери никогда не появятся и игрок застрянет. + // Секретка: +1 max HP один раз при первом входе (лечит заодно на 1). + if (room.type === 'secret' && !wasVisited) { + this.player.growMaxHp(1); + } + // Если врагов нет (напр. сокровищница/секретка) — зачищать нечего, + // открываем сразу, иначе двери никогда не появятся и игрок застрянет. if (room.enemies.length === 0) room.cleared = true; room.rebuildTiles(); } else { @@ -204,39 +217,45 @@ export class Game { if (input.moveX < 0) p.moveDir = 'left'; else if (input.moveX > 0) p.moveDir = 'right'; - const dx = mx * p.speed; - const dy = my * p.speed; - // Раздельное разрешение коллизий по осям: позволяет «скользить» вдоль стен. - p.x += dx; - if (collidesWall(p.box, room)) p.x -= dx; - p.y += dy; - if (collidesWall(p.box, room)) p.y -= dy; + moveEntity(p, mx * p.speed, my * p.speed, room); } private handleAttack(input: InputState, room: Room, p: Player): void { - let dir: Dir | null = null; - if (input.aimDir) dir = input.aimDir; - else if (input.attackHeld) dir = p.moveDir; + // Приоритет: явный прицел (вектор стрелок) → иначе направление движения. + let nx = 0, ny = 0; + let aim = false; + if (input.aimVec) { + nx = input.aimVec.x; + ny = input.aimVec.y; + aim = true; + } else if (input.attackHeld) { + [nx, ny] = DIR[p.moveDir]; + aim = true; + } - if (!dir || p.atkCD > 0) return; + if (!aim || p.atkCD > 0) return; + + const len = Math.hypot(nx, ny) || 1; + nx /= len; ny /= len; + // Для рендера и door-логики сохраняем facing как одно из 4 направлений. + p.facing = cardinalFromVec({ x: nx, y: ny }); - p.facing = dir; const w = p.currentWeapon; - p.atkCD = w.cooldown; - const [nx, ny] = DIR[dir]; + p.atkCD = p.effectiveCooldown(w); + const damage = p.effectiveDamage(w); if (w.type === 'ranged') { if (w.projectileType === 'beam') { // Лазерный луч — стационарная зона поражения. - const range = 70; + const range = w.beamRange ?? 70; const t = new Projectile(p.x + nx * range, p.y + ny * range, 0, 0, 'beam'); t.speed = 0; t.life = w.beamLife ?? 10; - t.damage = w.beamTickDmg ?? 2; + t.damage = (w.beamTickDmg ?? 2) * p.stats.damageMul; t.beamRadius = w.beamRadius ?? 44; room.tears.push(t); } else if (w.spreadCount && w.spreadCount > 1) { - const spread = 0.15; + const spread = w.spread ?? 0.15; const perpX = -ny; const perpY = nx; const projectileType = w.projectileType ?? 'tear'; @@ -244,19 +263,19 @@ export class Game { const off = (i - (w.spreadCount - 1) / 2) * spread; const sx = nx + perpX * off; const sy = ny + perpY * off; - const len = Math.hypot(sx, sy) || 1; - const t = new Projectile(p.x, p.y, sx / len, sy / len, projectileType); - this.applyWeaponProjectileStats(t, w); + const sl = Math.hypot(sx, sy) || 1; + const t = new Projectile(p.x, p.y, sx / sl, sy / sl, projectileType); + applyWeaponProjectileStats(t, w, p); room.tears.push(t); } } else { const t = new Projectile(p.x, p.y, nx, ny, w.projectileType ?? 'tear'); - this.applyWeaponProjectileStats(t, w); + applyWeaponProjectileStats(t, w, p); room.tears.push(t); } } else { - this.meleeSwing = new MeleeSwing(p.x, p.y, dir, { - damage: w.damage, + this.meleeSwing = new MeleeSwing(p.x, p.y, cardinalFromVec({ x: nx, y: ny }), { + damage, knockback: w.knockback, life: w.swingLife, sizeMul: w.swingSizeMul, @@ -285,15 +304,6 @@ export class Game { } } - /** Снаряд запоминает свойства оружия при выстреле, а не при попадании. */ - private applyWeaponProjectileStats(t: Projectile, w: WeaponDef): void { - t.damage = w.damage; - t.burnDamage = w.fireDmg ?? 1; - t.burnInterval = w.fireInterval ?? 10; - t.burnDuration = w.fireDuration ?? 0; - t.explosionRadius = w.explosionRadius ?? 0; - } - private updateTears(room: Room): void { for (const t of room.tears) { if (!t.alive) continue; @@ -325,15 +335,8 @@ export class Game { t.y += t.dy * t.speed; t.life--; - const col = Math.floor((t.x - OX) / TILE); - const row = Math.floor((t.y - OY) / TILE); - if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) { - this.explodeBomb(room, t); - t.life = 0; - continue; - } - if (room.tiles[row][col] === T_WALL) { - this.explodeBomb(room, t); + if (t.life <= 0 || projectileHitWall(t, room)) { + explodeBomb(room, t); t.life = 0; continue; } @@ -362,7 +365,7 @@ export class Game { } if (t.type !== 'laser') { t.life = 0; - this.explodeBomb(room, t); + explodeBomb(room, t); break; } } @@ -372,7 +375,7 @@ export class Game { room.chest.hp -= t.damage; if (t.type !== 'laser') { t.life = 0; - this.explodeBomb(room, t); + explodeBomb(room, t); } } } @@ -380,39 +383,32 @@ export class Game { room.tears = room.tears.filter((t) => t.alive); } - /** Взрыв бомбы: AoE-урон по врагам. */ - private explodeBomb(room: Room, t: Projectile): void { - if (t.type !== 'bomb') return; - const radius = t.explosionRadius || 60; - for (const e of room.enemies) { - if (!e.alive) continue; - if (dist(t.x, t.y, e.x, e.y) < radius) { - e.hp -= 3; - e.hitTimer = ENEMY.hitFlash; - // Отбрасывание от центра взрыва. - const dx = e.x - t.x; - const dy = e.y - t.y; - const d = Math.hypot(dx, dy) || 1; - e.knx = (dx / d) * 12; - e.kny = (dy / d) * 12; - } - } - } - - /** Сундук уничтожен — спавним оружие. */ + /** Сундук уничтожен — спавним предмет или оружие (50/50). */ private updateChest(room: Room): void { if (!room.chest || room.pickup) return; if (room.chest.alive) return; - const weaponId = pickChestWeapon(this.rng); - room.pickup = new WeaponPickup(room.chest.x, room.chest.y, weaponId); + const dropWeapon = this.rng.chance(0.5); + if (dropWeapon) { + room.pickup = Pickup.weapon(room.chest.x, room.chest.y, pickChestWeapon(this.rng)); + } else { + const itemId = this.rng.pick(ALL_ITEM_IDS); + room.pickup = Pickup.item(room.chest.x, room.chest.y, itemId); + } room.chest = null; } - /** Подбор оружия игроком. */ + /** Подбор пикапа игроком (оружие → слот, предмет → статы). */ private updatePickup(room: Room, p: Player): void { if (!room.pickup) return; if (overlap(p.box, room.pickup.box)) { - p.addWeapon(room.pickup.weaponId); + const pk = room.pickup; + if (pk.kind === 'weapon' && pk.weaponId !== undefined) { + p.addWeapon(pk.weaponId); + } else if (pk.kind === 'item' && pk.itemId !== undefined) { + const item: ItemId = pk.itemId; + const def = ITEMS[item]; + applyItem(p.stats, def, (hpBonus) => p.growMaxHp(hpBonus)); + } room.pickup = null; } } @@ -420,9 +416,20 @@ export class Game { private updateEnemies(room: Room, p: Player): number { let aliveCount = 0; const newEnemies: Enemy[] = []; + const ctx: AIContext = { + room, player: p, rng: this.rng, floor: this.floor, floorRules: this.floorRules, newEnemies, + }; + + // Запоминаем splitter'ов, которые умерли в этом шаге — после цикла спавним + // их детей. (Делаем это в конце, чтобы не мутировать массив во время итерации.) + const deadSplitters: Enemy[] = []; for (const e of room.enemies) { - if (!e.alive) continue; + // Мёртвый враг: ловим splitter для распада, иначе пропускаем. + if (!e.alive) { + if (e.type === 'splitter') deadSplitters.push(e); + continue; + } if (e.hitTimer > 0) e.hitTimer--; @@ -434,8 +441,11 @@ export class Game { e.hitTimer = ENEMY.hitFlash; } } - if (!e.alive) continue; - aliveCount++; + if (!e.alive) { + // Умер от горения/прошлого удара в этом шаге. + if (e.type === 'splitter') deadSplitters.push(e); + continue; + } // Фаза отбрасывания: летит по инерции, ИИ не работает. if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) { @@ -445,20 +455,21 @@ export class Game { if (collidesWall(e.box, room)) e.y -= e.kny * 3; e.knx *= ENEMY.knockbackDecay; e.kny *= ENEMY.knockbackDecay; + aliveCount++; + if (e.atkTimer > 0) e.atkTimer--; continue; } e.knx = 0; e.kny = 0; - if (e.type === 'boss' && this.milestoneBossFloor(this.floor)) { - this.updateMilestoneBoss(e, room, p, newEnemies); - } else if (e.type === 'shooter') { - this.updateShooter(e, room, p); - } else if (e.type === 'charger') { - this.updateCharger(e, room, p); - } else { - this.updateChaser(e, room, p); - } + aliveCount++; + + runAI(e, ctx); + + // Если за этот шаг враг умер от ИИ-фазы или снаряда (маловероятно, + // но возможно при касании уже летящего), отслеживаем. + if (!e.alive && e.type === 'splitter') deadSplitters.push(e); + if (!e.alive) continue; // Контактный урон по игроку. if (e.atkTimer > 0) e.atkTimer--; @@ -469,156 +480,25 @@ export class Game { if (p.hp <= 0) { p.hp = 0; this.gameOver = true; + for (const parent of deadSplitters) newEnemies.push(...spawnSplitterChildren(parent)); + room.enemies.push(...newEnemies); + room.enemies = room.enemies.filter((en) => en.alive); return aliveCount; } } } + // Распад splitter'ов на двух fast. + for (const parent of deadSplitters) { + newEnemies.push(...spawnSplitterChildren(parent)); + } + room.enemies.push(...newEnemies); + // Убираем мёртвых — иначе массив растёт и зашумляет итерации/рендер. + room.enemies = room.enemies.filter((en) => en.alive); return aliveCount; } - /** Этаж кратный 5, начиная с 5. */ - private milestoneBossFloor(floor: number): boolean { - return floor % 5 === 0 && floor >= 5; - } - - /** Босс на milestone-этаже: фазы, стрельба, миньоны. */ - private updateMilestoneBoss(e: Enemy, room: Room, p: Player, newEnemies: Enemy[]): void { - const maxPhase = this.floor <= 5 ? 2 : 3; - const hpRatio = e.hp / e.maxHp; - - let targetPhase = 1; - if (maxPhase >= 2 && hpRatio < 0.66) targetPhase = 2; - if (maxPhase >= 3 && hpRatio < 0.33) targetPhase = 3; - - if (targetPhase > e.phase) { - e.phase = targetPhase; - e.phaseChanged = true; - e.hitTimer = 15; // визуальная вспышка (для рендера) - } - - const phaseSpeedMul = targetPhase >= 3 ? 1.8 : targetPhase === 2 ? 1.35 : 1.0; - const effectiveSpeed = e.speed * phaseSpeedMul; - - // Движение к игроку. - const dx = p.x - e.x; - const dy = p.y - e.y; - const d = Math.hypot(dx, dy); - if (d > 0 && d < ENEMY.aggroRange) { - const mx = (dx / d) * effectiveSpeed; - const my = (dy / d) * effectiveSpeed; - e.x += mx; - if (collidesWall(e.box, room)) e.x -= mx; - e.y += my; - if (collidesWall(e.box, room)) e.y -= my; - } - - // Стрельба снарядами (фаза 2+). - if (targetPhase >= 2) { - if (e.shootTimer > 0) e.shootTimer--; - const shootCD = targetPhase >= 3 ? 25 : 40; - if (e.shootTimer <= 0 && d < 400 && d > 50) { - e.shootTimer = shootCD; - const nd = d || 1; - const t = new Projectile(e.x, e.y, dx / nd, dy / nd, 'tear'); - t.hostile = true; - t.damage = 1 + Math.floor(this.floor / 5); - t.speed = 3; - t.life = 60; - room.tears.push(t); - } - } - - // Спавн миньонов (фаза 3). - if (targetPhase >= 3) { - if (e.spawnTimer > 0) e.spawnTimer--; - if (e.spawnTimer <= 0) { - e.spawnTimer = 120; - const rng = this.rng; - const mx = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE; - const my = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE; - const er = this.floorRules.enemies; - newEnemies.push(new Enemy(mx, my, 'fast', { hpMul: er.hpMul * 1.5, speedMul: er.speedMul * 1.2 })); - } - } - } - - /** Стандартное преследование (normal, fast, tank, boss). */ - private updateChaser(e: Enemy, room: Room, p: Player): void { - const dx = p.x - e.x; - const dy = p.y - e.y; - const d = Math.hypot(dx, dy); - if (d > 0 && d < ENEMY.aggroRange) { - const mx = (dx / d) * e.speed; - const my = (dy / d) * e.speed; - e.x += mx; - if (collidesWall(e.box, room)) e.x -= mx; - e.y += my; - if (collidesWall(e.box, room)) e.y -= my; - } - } - - /** Зарядчик: бежит прямо на игрока с удвоенной скоростью. */ - private updateCharger(e: Enemy, room: Room, p: Player): void { - if (e.chargeTimer > 0) e.chargeTimer--; - const dx = p.x - e.x; - const dy = p.y - e.y; - const d = Math.hypot(dx, dy); - if (d > 0 && d < ENEMY.aggroRange) { - const speedMul = e.chargeTimer <= 0 && d < 150 ? 2.5 : 1.0; - if (speedMul > 1) e.chargeTimer = 40; // перезарядка рывка - const mx = (dx / d) * e.speed * speedMul; - const my = (dy / d) * e.speed * speedMul; - e.x += mx; - if (collidesWall(e.box, room)) e.x -= mx; - e.y += my; - if (collidesWall(e.box, room)) e.y -= my; - } - } - - /** Стрелок: держит дистанцию, стреляет снарядами. */ - private updateShooter(e: Enemy, room: Room, p: Player): void { - const dx = p.x - e.x; - const dy = p.y - e.y; - const d = Math.hypot(dx, dy); - - // Держит дистанцию ~200 px. - if (d > 0 && d < ENEMY.aggroRange) { - if (d < 150) { - // Слишком близко — отступает. - const mx = -(dx / d) * e.speed; - const my = -(dy / d) * e.speed; - e.x += mx; - if (collidesWall(e.box, room)) e.x -= mx; - e.y += my; - if (collidesWall(e.box, room)) e.y -= my; - } else { - const mx = (dx / d) * e.speed * 0.5; - const my = (dy / d) * e.speed * 0.5; - e.x += mx; - if (collidesWall(e.box, room)) e.x -= mx; - e.y += my; - if (collidesWall(e.box, room)) e.y -= my; - } - } - - // Стрельба. - if (e.shootTimer > 0) e.shootTimer--; - if (e.shootTimer <= 0 && d < 350 && d > 40) { - e.shootTimer = 45; - const nd = d || 1; - const nx = dx / nd; - const ny = dy / nd; - const t = new Projectile(e.x, e.y, nx, ny, 'tear'); - t.hostile = true; - t.damage = 1; - t.speed = 3.5; - t.life = 60; - room.tears.push(t); - } - } - // ── Переходы и победа ───────────────────────────────────── private checkTransition(input: InputState): void { diff --git a/src/core/entities/Pickup.ts b/src/core/entities/Pickup.ts new file mode 100644 index 0000000..e1add98 --- /dev/null +++ b/src/core/entities/Pickup.ts @@ -0,0 +1,48 @@ +import type { Box } from '../types'; +import type { WeaponId } from '../weapons'; +import type { ItemId } from '../items'; + +/** Что выпало из сундука: новое оружие ИЛИ пассивный предмет. */ +export type PickupKind = 'weapon' | 'item'; + +/** + * Пикап на полу. Может быть либо оружие (заменяет экипированный слот), + * либо пассивный предмет (модифицирует статы игрока при подборе). + */ +export class Pickup { + x: number; + y: number; + readonly kind: PickupKind; + /** ID оружия, если kind === 'weapon'. */ + readonly weaponId?: WeaponId; + /** ID предмета, если kind === 'item'. */ + readonly itemId?: ItemId; + readonly w = 30; + readonly h = 30; + + private constructor(x: number, y: number, kind: PickupKind, weaponId?: WeaponId, itemId?: ItemId) { + this.x = x; + this.y = y; + this.kind = kind; + this.weaponId = weaponId; + this.itemId = itemId; + } + + static weapon(x: number, y: number, weaponId: WeaponId): Pickup { + return new Pickup(x, y, 'weapon', weaponId); + } + + static item(x: number, y: number, itemId: ItemId): Pickup { + return new Pickup(x, y, 'item', undefined, itemId); + } + + get box(): Box { + return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h }; + } +} + +/** + * @deprecated Используйте Pickup. WeaponPickup оставлен как псевдоним для + * обратной совместимости со старым кодом/тестами. + */ +export const WeaponPickup = Pickup; diff --git a/src/core/entities/Player.ts b/src/core/entities/Player.ts index 99b46f7..d4544f9 100644 --- a/src/core/entities/Player.ts +++ b/src/core/entities/Player.ts @@ -1,7 +1,31 @@ -import { PLAYER, MODE_RANGED } from '../../config'; +import { PLAYER, MODE_RANGED, MODE_MELEE } from '../../config'; import { WEAPONS, type WeaponId, type WeaponDef } from '../weapons'; import type { CombatMode, Box, Dir } from '../types'; +/** + * Статы-множители, которые модифицируют базовые характеристики оружия. + * Все начинают с 1 (нейтрально); пассивные предметы и баффы их меняют. + * Сделано мультипликативно поверх WeaponDef, чтобы предметы и оружие + * комбинировались независимо (как в Isaac: апгрейды работают с любым оружием). + */ +export interface PlayerStats { + /** Множитель урона выстрела/взмаха. */ + damageMul: number; + /** Множитель скорости атаки (больше → чаще стреляет).Cooldown = base / fireRateMul. */ + fireRateMul: number; + /** Множитель дальности полёта снаряда (в шагах жизни). */ + rangeMul: number; + /** Множитель скорости полёта снаряда. */ + shotSpeedMul: number; +} + +export const NEUTRAL_STATS: PlayerStats = { + damageMul: 1, + fireRateMul: 1, + rangeMul: 1, + shotSpeedMul: 1, +}; + /** * Игрок. Только данные и геометрия — никакой отрисовки. * prevX/prevY хранят позицию на прошлом шаге для плавной интерполяции @@ -29,6 +53,9 @@ export class Player { /** 0 или 1 — какой слот сейчас экипирован. */ equipped: 0 | 1 = 0; + /** Текущие множители. Меняются предметами/баффами. */ + stats: PlayerStats = { ...NEUTRAL_STATS }; + /** Переопределения из правил уровня; по умолчанию — баланс из config. */ constructor(rules: { maxHp?: number; speed?: number } = {}) { this.maxHp = rules.maxHp ?? PLAYER.maxHp; @@ -45,10 +72,31 @@ export class Player { return this.weapons[this.equipped]; } + /** Эффективный урон оружия с учётом статов игрока. */ + effectiveDamage(w: WeaponDef): number { + return w.damage * this.stats.damageMul; + } + + /** Эффективная перезарядка оружия с учётом скорости атаки. */ + effectiveCooldown(w: WeaponDef): number { + return Math.max(1, Math.round(w.cooldown / this.stats.fireRateMul)); + } + /** Подобрать оружие — заменяет текущий экипированный слот. */ addWeapon(id: WeaponId): void { this.weapons[this.equipped] = WEAPONS[id]; - this.mode = WEAPONS[id].type === 'ranged' ? MODE_RANGED : 1; + this.mode = WEAPONS[id].type === 'ranged' ? MODE_RANGED : MODE_MELEE; + } + + /** + * Увеличить максимальное HP на bonus и подлечить на ту же величину. + * maxHp readonly снаружи, поэтому меняем через этот метод — он же не + * позволяет уйти в отрицательные значения. + */ + growMaxHp(bonus: number): void { + if (bonus <= 0) return; + (this as { maxHp: number }).maxHp += bonus; + this.hp = Math.min(this.maxHp, this.hp + bonus); } /** Поставить позицию мгновенно, сбросив интерполяцию (телепорт). */ diff --git a/src/core/entities/WeaponPickup.ts b/src/core/entities/WeaponPickup.ts deleted file mode 100644 index d910b7e..0000000 --- a/src/core/entities/WeaponPickup.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Box } from '../types'; -import type { WeaponId } from '../weapons'; - -export class WeaponPickup { - x: number; - y: number; - readonly weaponId: WeaponId; - readonly w = 30; - readonly h = 30; - - constructor(x: number, y: number, weaponId: WeaponId) { - this.x = x; - this.y = y; - this.weaponId = weaponId; - } - - get box(): Box { - return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h }; - } -} diff --git a/src/core/items.ts b/src/core/items.ts new file mode 100644 index 0000000..7629e44 --- /dev/null +++ b/src/core/items.ts @@ -0,0 +1,78 @@ +/** + * items.ts — пассивные предметы (как в Isaac). + * + * Предмет модифицирует статы игрока (damageMul, fireRateMul и т.д.). + * В отличие от оружия (которое занимает слот и определяет тип атаки), + * предметов можно собрать сколько угодно — они стакаются в общей сумме + * статов. Это база для «билдов»: чувак, накопивший +damage и +fireRate, + * к боссу придёт совсем с другой огневой мощью. + */ +import type { PlayerStats } from './entities/Player'; + +export type ItemId = + | 'sad-onion' // +скорострельность + | 'cricket-head' // +урон + | 'lemon-mishap' // +дальность + | 'lucky-toe' // +всё понемногу + | 'blood-penny' // +HP + | 'speed-ball'; // +скорость полёта снаряда + +export interface ItemDef { + id: ItemId; + name: string; + description: string; + /** Изменения стат, которые применяются при подборе. */ + stats?: Partial; + /** Сколько добавить к максимальному HP (и текущему). */ + maxHpBonus?: number; +} + +export const ITEMS: Record = { + 'sad-onion': { + id: 'sad-onion', name: 'Грустный лук', + description: '+35% к скорострельности.', + stats: { fireRateMul: 0.35 }, // это дельта, применяется как += 0.35 + }, + 'cricket-head': { + id: 'cricket-head', name: 'Голова сверчка', + description: '+50% к урону.', + stats: { damageMul: 0.5 }, + }, + 'lemon-mishap': { + id: 'lemon-mishap', name: 'Лимонная неприятность', + description: '+60% к дальности.', + stats: { rangeMul: 0.6 }, + }, + 'speed-ball': { + id: 'speed-ball', name: 'Скоростной шар', + description: '+40% к скорости снаряда.', + stats: { shotSpeedMul: 0.4 }, + }, + 'lucky-toe': { + id: 'lucky-toe', name: 'Счастливый палец', + description: '+15% урон, +15% скорострельность.', + stats: { damageMul: 0.15, fireRateMul: 0.15 }, + }, + 'blood-penny': { + id: 'blood-penny', name: 'Кровавый пенс', + description: '+2 к макс. HP и лечит на 2.', + maxHpBonus: 2, + }, +}; + +/** Все айдishники предметов — для случайного дропа. */ +export const ALL_ITEM_IDS: readonly ItemId[] = Object.keys(ITEMS) as ItemId[]; + +/** + * Применить предмет к статам/игроку. Используется при подборе. + * Стаки: каждый предмет модифицирует текущие множители дельтой. + */ +export function applyItem(stats: PlayerStats, item: ItemDef, onMaxHp: (bonus: number) => void): void { + if (item.stats) { + if (item.stats.damageMul) stats.damageMul += item.stats.damageMul; + if (item.stats.fireRateMul) stats.fireRateMul += item.stats.fireRateMul; + if (item.stats.rangeMul) stats.rangeMul += item.stats.rangeMul; + if (item.stats.shotSpeedMul) stats.shotSpeedMul += item.stats.shotSpeedMul; + } + if (item.maxHpBonus) onMaxHp(item.maxHpBonus); +} diff --git a/src/core/systems/ai.ts b/src/core/systems/ai.ts new file mode 100644 index 0000000..9e64f04 --- /dev/null +++ b/src/core/systems/ai.ts @@ -0,0 +1,171 @@ +import { BOSS, ENEMY, OX, OY, TILE, COLS, ROWS } from '../../config'; +import { moveEntity } from './movement'; +import { Enemy } from '../entities/Enemy'; +import { Projectile } from '../entities/Projectile'; +import type { Room } from '../world/Room'; +import type { Player } from '../entities/Player'; +import type { Rng } from '../rng'; +import type { LevelRules } from '../rules'; + +/** + * Контекст, нужный ИИ врагов на одном шаге. Передаётся извне (Game), чтобы + * сами функции ИИ оставались чистыми от глобального состояния и их было + * удобно тестировать. + */ +export interface AIContext { + room: Room; + player: Player; + rng: Rng; + floor: number; + floorRules: LevelRules; + /** Сюда босс складывает свежеспавненных миньёнов — Game добавит их в room.enemies. */ + newEnemies: Enemy[]; +} + +/** Этаж кратный 5, начиная с 5 — на нём босс получает фазы и спавн миньёнов. */ +export function isMilestoneBossFloor(floor: number): boolean { + return floor % 5 === 0 && floor >= 5; +} + +/** + * Раздвоение splitter'а при смерти: спавнит двух мелких `fast` по бокам. + * Возвращает свежезаспавненных врагов — Game добавит их в room.enemies. + */ +export function spawnSplitterChildren(parent: Enemy): Enemy[] { + const offset = 14; + return [ + new Enemy(parent.x - offset, parent.y, 'fast'), + new Enemy(parent.x + offset, parent.y, 'fast'), + ]; +} + +type AIHandler = (e: Enemy, ctx: AIContext) => void; + +/** + * Диспетчер ИИ по типу врага. Добавишь новый тип — допиши ветку здесь + * (и в EnemyType/config). Заменяет прежний if/else-if каскад в Game.ts. + */ +export function runAI(e: Enemy, ctx: AIContext): void { + const handler = pickHandler(e, ctx.floor); + handler(e, ctx); +} + +function pickHandler(e: Enemy, floor: number): AIHandler { + if (e.type === 'boss' && isMilestoneBossFloor(floor)) return updateMilestoneBoss; + if (e.type === 'shooter') return updateShooter; + if (e.type === 'charger') return updateCharger; + // normal, fast, tank, обычный boss — просто догоняют. + return updateChaser; +} + +/** Стандартное преследование (normal, fast, tank, обычный boss). */ +function updateChaser(e: Enemy, ctx: AIContext): void { + const { player: p, room } = ctx; + const dx = p.x - e.x; + const dy = p.y - e.y; + const d = Math.hypot(dx, dy); + if (d > 0 && d < ENEMY.aggroRange) { + moveEntity(e, (dx / d) * e.speed, (dy / d) * e.speed, room); + } +} + +/** Зарядчик: бежит прямо на игрока, время от времени делая рывок ×2.5. */ +function updateCharger(e: Enemy, ctx: AIContext): void { + const { player: p, room } = ctx; + if (e.chargeTimer > 0) e.chargeTimer--; + const dx = p.x - e.x; + const dy = p.y - e.y; + const d = Math.hypot(dx, dy); + if (d > 0 && d < ENEMY.aggroRange) { + const speedMul = e.chargeTimer <= 0 && d < 150 ? 2.5 : 1.0; + if (speedMul > 1) e.chargeTimer = 40; // перезарядка рывка + moveEntity(e, (dx / d) * e.speed * speedMul, (dy / d) * e.speed * speedMul, room); + } +} + +/** Стрелок: держит дистанцию ~150–200 px, периодически стреляет в игрока. */ +function updateShooter(e: Enemy, ctx: AIContext): void { + const { player: p, room } = ctx; + const dx = p.x - e.x; + const dy = p.y - e.y; + const d = Math.hypot(dx, dy); + + if (d > 0 && d < ENEMY.aggroRange) { + if (d < 150) { + // Слишком близко — отступает. + moveEntity(e, -(dx / d) * e.speed, -(dy / d) * e.speed, room); + } else { + moveEntity(e, (dx / d) * e.speed * 0.5, (dy / d) * e.speed * 0.5, room); + } + } + + // Стрельба. + if (e.shootTimer > 0) e.shootTimer--; + if (e.shootTimer <= 0 && d < 350 && d > 40) { + e.shootTimer = 45; + const nd = d || 1; + const t = new Projectile(e.x, e.y, dx / nd, dy / nd, 'tear'); + t.hostile = true; + t.damage = 1; + t.speed = 3.5; + t.life = 60; + ctx.room.tears.push(t); + } +} + +/** Milestone-босс (этаж 5/10/15): фазы HP, стрельба с фазы 2, миньёны с фазы 3. */ +function updateMilestoneBoss(e: Enemy, ctx: AIContext): void { + const { player: p, room, rng, floor, floorRules, newEnemies } = ctx; + const maxPhase = floor <= 5 ? 2 : 3; + const hpRatio = e.hp / e.maxHp; + + let targetPhase = 1; + if (maxPhase >= 2 && hpRatio < 0.66) targetPhase = 2; + if (maxPhase >= 3 && hpRatio < 0.33) targetPhase = 3; + + if (targetPhase > e.phase) { + e.phase = targetPhase; + e.phaseChanged = true; + e.hitTimer = 15; // визуальная вспышка (для рендера) + } + + const phaseSpeedMul = targetPhase >= 3 ? 1.8 : targetPhase === 2 ? 1.35 : 1.0; + const effectiveSpeed = e.speed * phaseSpeedMul; + + // Движение к игроку. + const dx = p.x - e.x; + const dy = p.y - e.y; + const d = Math.hypot(dx, dy); + if (d > 0 && d < ENEMY.aggroRange) { + moveEntity(e, (dx / d) * effectiveSpeed, (dy / d) * effectiveSpeed, room); + } + + // Стрельба снарядами (фаза 2+). + if (targetPhase >= 2) { + if (e.shootTimer > 0) e.shootTimer--; + const shootCD = targetPhase >= 3 ? 25 : 40; + if (e.shootTimer <= 0 && d < 400 && d > 50) { + e.shootTimer = shootCD; + const nd = d || 1; + const t = new Projectile(e.x, e.y, dx / nd, dy / nd, 'tear'); + t.hostile = true; + t.damage = 1 + Math.floor(floor / 5); + t.speed = 3; + t.life = 60; + room.tears.push(t); + } + } + + // Спавн миньёнов (фаза 3), но не больше BOSS.maxMinions живых — иначе комната не зачистится. + if (targetPhase >= 3) { + if (e.spawnTimer > 0) e.spawnTimer--; + const aliveMinions = room.enemies.filter((en) => en !== e && en.alive).length; + if (e.spawnTimer <= 0 && aliveMinions < BOSS.maxMinions) { + e.spawnTimer = BOSS.minionInterval; + const mx = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE; + const my = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE; + const er = floorRules.enemies; + newEnemies.push(new Enemy(mx, my, 'fast', { hpMul: er.hpMul * 1.5, speedMul: er.speedMul * 1.2 })); + } + } +} diff --git a/src/core/systems/movement.ts b/src/core/systems/movement.ts new file mode 100644 index 0000000..a882730 --- /dev/null +++ b/src/core/systems/movement.ts @@ -0,0 +1,26 @@ +import { collidesWall } from './collision'; +import type { Room } from '../world/Room'; +import type { Box } from '../types'; + +/** + * Сущность с прямоугольным хитбоксом и позицией, пригодная для скользящего + * перемещения (движение разрешается по осям раздельно, скользя вдоль стен). + */ +export interface Movable { + x: number; + y: number; + box: Box; +} + +/** + * Сдвинуть сущность на (dx, dy) с разрешением коллизий по осям раздельно. + * Применяет X (откатывая при столкновении), затем Y — это даёт «скольжение» + * вдоль стен. Игрок, враги, боссы — все ходят через эту функцию, чтобы + * поведение у стен было единым. + */ +export function moveEntity(e: Movable, dx: number, dy: number, room: Room): void { + e.x += dx; + if (collidesWall(e.box, room)) e.x -= dx; + e.y += dy; + if (collidesWall(e.box, room)) e.y -= dy; +} diff --git a/src/core/systems/projectiles.ts b/src/core/systems/projectiles.ts new file mode 100644 index 0000000..a53281c --- /dev/null +++ b/src/core/systems/projectiles.ts @@ -0,0 +1,65 @@ +import { BOSS, ENEMY, PROJECTILE, TILE, OX, OY, COLS, ROWS, T_WALL } from '../../config'; +import { dist } from '../util'; +import { Projectile } from '../entities/Projectile'; +import type { Player, PlayerStats } from '../entities/Player'; +import type { Room } from '../world/Room'; +import type { WeaponDef } from '../weapons'; + +/** + * Применяет к свежему снаряду характеристики оружия (урон, горение, радиус + * взрыва) с учётом статов игрока (damageMul, rangeMul, shotSpeedMul). + * Снаряд запоминает свойства на момент выстрела — поэтому смена оружия или + * статов после не меняет урон уже летящей «слезы». + */ +export function applyWeaponProjectileStats(t: Projectile, w: WeaponDef, stats: PlayerStats | Player): void { + const s: PlayerStats = 'stats' in stats ? stats.stats : stats; + t.damage = w.damage * s.damageMul; + t.burnDamage = (w.fireDmg ?? 1) * s.damageMul; + t.burnInterval = w.fireInterval ?? 10; + t.burnDuration = w.fireDuration ?? 0; + t.explosionRadius = w.explosionRadius ?? 0; + t.speed *= s.shotSpeedMul; + t.life = Math.round(t.life * s.rangeMul); +} + +/** Взрыв бомбы: AoE-урон по всем врагам в радиусе, с отбрасыванием от центра. */ +export function explodeBomb(room: Room, t: Projectile): void { + if (t.type !== 'bomb') return; + const radius = t.explosionRadius || 60; + for (const e of room.enemies) { + if (!e.alive) continue; + if (dist(t.x, t.y, e.x, e.y) < radius) { + e.hp -= 3; + e.hitTimer = ENEMY.hitFlash; + const dx = e.x - t.x; + const dy = e.y - t.y; + const d = Math.hypot(dx, dy) || 1; + e.knx = (dx / d) * 12; + e.kny = (dy / d) * 12; + } + } +} + +/** True, если снаряд вышел за пределы комнаты или уткнулся в стену. */ +export function projectileHitWall(t: Projectile, room: Room): boolean { + const col = Math.floor((t.x - OX) / TILE); + const row = Math.floor((t.y - OY) / TILE); + if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return true; + if (room.tiles[row][col] === T_WALL) return true; + return false; +} + +/** + * Параметры, общие для всех боссов: лимит миньёнов и интервал их спавна + * вынесены в BOSS (см. config), чтобы их можно было крутить отдельно от ИИ. + */ +export const BOSS_LIMITS = { + maxMinions: BOSS.maxMinions, + minionInterval: BOSS.minionInterval, +} as const; + +/** Текущая скорость снаряда по умолчанию (для новых снарядов, не задающих свою). */ +export const PROJECTILE_DEFAULTS = { + speed: PROJECTILE.speed, + life: PROJECTILE.life, +} as const; diff --git a/src/core/systems/spawner.ts b/src/core/systems/spawner.ts index 402e20c..ec72c9e 100644 --- a/src/core/systems/spawner.ts +++ b/src/core/systems/spawner.ts @@ -34,12 +34,13 @@ function isSpawnSpotClear( function pickEnemyType(room: Room, rng: Rng, fastChance: number): EnemyType { if (room.type === 'boss') return 'boss'; const roll = rng.next(); - // normal: до 0.4, fast: 0.4-0.6, charger: 0.6-0.75, tank: 0.75-0.88, shooter: 0.88-1.0 + // normal: до 0.4, fast: 0.4-0.6, charger: 0.6-0.75, tank: 0.75-0.88, shooter: 0.88-0.95, splitter: 0.95-1.0 if (roll < 0.4) return 'normal'; if (roll < 0.4 + fastChance * 0.7) return 'fast'; if (roll < 0.7) return 'charger'; if (roll < 0.85) return 'tank'; - return 'shooter'; + if (roll < 0.95) return 'shooter'; + return 'splitter'; } export function spawnEnemies( @@ -55,7 +56,7 @@ export function spawnEnemies( const count = room.type === 'boss' ? 1 : - room.type === 'treasure' ? 0 : + room.type === 'treasure' || room.type === 'secret' ? 0 : Math.max(1, Math.round((SPAWN.normalMin + rng.int(0, SPAWN.normalExtra)) * er.densityMul)); const door = DOOR[entryDir]; diff --git a/src/core/types.ts b/src/core/types.ts index e96658c..7338ac5 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,9 +1,9 @@ /** Общие типы данных, на которые опирается вся игра. */ -export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss'; +export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss' | 'secret'; export type Dir = 'up' | 'down' | 'left' | 'right'; export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE -export type EnemyType = 'normal' | 'fast' | 'boss' | 'charger' | 'tank' | 'shooter'; +export type EnemyType = 'normal' | 'fast' | 'boss' | 'charger' | 'tank' | 'shooter' | 'splitter'; export type ProjectileType = 'tear' | 'fireball' | 'bomb' | 'boomerang' | 'laser' | 'beam'; /** Прямоугольник (axis-aligned bounding box) для коллизий. */ diff --git a/src/core/weapons.ts b/src/core/weapons.ts index b2f113b..967f2db 100644 --- a/src/core/weapons.ts +++ b/src/core/weapons.ts @@ -10,6 +10,8 @@ export interface WeaponDef { cooldown: number; projectileType?: ProjectileType; spreadCount?: number; + /** Угол бокового отклонения каждого снаряда при spreadCount > 1 (доля от перпендикуляра). */ + spread?: number; swingSizeMul?: number; swingLife?: number; knockback?: number; @@ -20,16 +22,20 @@ export interface WeaponDef { beamLife?: number; // длительность лазерного луча beamRadius?: number; // радиус поражения луча beamTickDmg?: number; // урон за тик луча + beamRange?: number; // дальность постановки центра луча от игрока } export const WEAPONS: Record = { tears: { id: 'tears', name: 'Слёзы', type: 'ranged', damage: 1, cooldown: 10, projectileType: 'tear' }, melee: { id: 'melee', name: 'Кулак', type: 'melee', damage: 2, cooldown: 22 }, - shotgun: { id: 'shotgun', name: 'Дробовик', type: 'ranged', damage: 1, cooldown: 18, projectileType: 'tear', spreadCount: 3 }, + shotgun: { id: 'shotgun', name: 'Дробовик', type: 'ranged', damage: 1, cooldown: 18, projectileType: 'tear', spreadCount: 3, spread: 0.15 }, axe: { id: 'axe', name: 'Топор', type: 'melee', damage: 4, cooldown: 35, swingSizeMul: 1.5, swingLife: 15, knockback: 15 }, staff: { id: 'staff', name: 'Посох', type: 'ranged', damage: 1, cooldown: 20, projectileType: 'fireball', fireDmg: 1, fireInterval: 10, fireDuration: 50 }, whip: { id: 'whip', name: 'Хлыст', type: 'melee', damage: 3, cooldown: 18, swingSizeMul: 2.5, swingLife: 12, knockback: 16 }, bomb: { id: 'bomb', name: 'Бомба', type: 'ranged', damage: 0, cooldown: 35, projectileType: 'bomb', explosionRadius: 80 }, boomerang: { id: 'boomerang', name: 'Бумеранг', type: 'ranged', damage: 2, cooldown: 25, projectileType: 'boomerang' }, - laser: { id: 'laser', name: 'Лазер', type: 'ranged', damage: 1, cooldown: 20, projectileType: 'beam', beamLife: 12, beamRadius: 44, beamTickDmg: 2 }, + laser: { + id: 'laser', name: 'Лазер', type: 'ranged', damage: 1, cooldown: 20, + projectileType: 'beam', beamLife: 12, beamRadius: 44, beamTickDmg: 2, beamRange: 70, + }, }; diff --git a/src/core/world/Room.ts b/src/core/world/Room.ts index d0d652f..0c06e84 100644 --- a/src/core/world/Room.ts +++ b/src/core/world/Room.ts @@ -3,7 +3,7 @@ import { buildTiles } from './tiles'; import type { Enemy } from '../entities/Enemy'; import type { Projectile } from '../entities/Projectile'; import type { Chest } from '../entities/Chest'; -import type { WeaponPickup } from '../entities/WeaponPickup'; +import type { Pickup } from '../entities/Pickup'; /** * Комната дандженa. Хранит свой тип, набор дверей, состояние «зачищена/ @@ -20,7 +20,7 @@ export class Room { enemies: Enemy[] = []; tears: Projectile[] = []; chest: Chest | null = null; - pickup: WeaponPickup | null = null; + pickup: Pickup | null = null; tiles: number[][]; constructor(c: number, r: number, type: RoomType) { diff --git a/src/core/world/RoomMap.ts b/src/core/world/RoomMap.ts index db4042c..dbdbf9b 100644 --- a/src/core/world/RoomMap.ts +++ b/src/core/world/RoomMap.ts @@ -76,6 +76,8 @@ export class RoomMap { type = 'boss'; } else if (rng.chance(SPAWN.treasureChance) && count >= 2) { type = 'treasure'; + } else if (rng.chance(SPAWN.secretChance) && count >= 3) { + type = 'secret'; } this.add(nc, nr, type); diff --git a/src/input/InputState.ts b/src/input/InputState.ts index b06533f..92c1978 100644 --- a/src/input/InputState.ts +++ b/src/input/InputState.ts @@ -7,13 +7,18 @@ import type { Dir } from '../core/types'; * контроллер, отдающий такой же InputState. * * Поля делятся на два вида: - * • удерживаемые (move*, aimDir, attackHeld) — читаются каждый шаг симуляции; + * • удерживаемые (move*, aimVec, attackHeld) — читаются каждый шаг симуляции; * • однократные «edge» (toggleWeapon, restart) — срабатывают один раз на нажатие. */ export interface InputState { moveX: number; // -1 влево, +1 вправо, 0 нет moveY: number; // -1 вверх, +1 вниз, 0 нет - aimDir: Dir | null; // прицеливание стрелками (приоритетнее attackHeld) + /** + * Вектор прицеливания (например, из стрелок). Может быть диагональным: + * {1,-1} = вверх-вправо. null = игрок не целится явным образом. Не обязан + * быть нормализованным — приведением займётся логика. + */ + aimVec: { x: number; y: number } | null; attackHeld: boolean; // атака «по ходу движения» (пробел) toggleWeapon: boolean; // сменить оружие (однократно) restart: boolean; // рестарт на экране конца игры (однократно) @@ -30,7 +35,7 @@ export function emptyInput(): InputState { return { moveX: 0, moveY: 0, - aimDir: null, + aimVec: null, attackHeld: false, toggleWeapon: false, restart: false, @@ -38,12 +43,25 @@ export function emptyInput(): InputState { }; } -/** Жмёт ли игрок в сторону dir (движением ИЛИ прицеливанием) — для переходов. */ +/** + * True, если вектор прицеливания или движения указывает в сторону dir. + * Нужно для переходов между комнатами (можно жать стрелку ИЛИ движение). + */ export function pressingDir(input: InputState, dir: Dir): boolean { switch (dir) { - case 'up': return input.moveY < 0 || input.aimDir === 'up'; - case 'down': return input.moveY > 0 || input.aimDir === 'down'; - case 'left': return input.moveX < 0 || input.aimDir === 'left'; - case 'right': return input.moveX > 0 || input.aimDir === 'right'; + case 'up': return input.moveY < 0 || (!!input.aimVec && input.aimVec.y < 0); + case 'down': return input.moveY > 0 || (!!input.aimVec && input.aimVec.y > 0); + case 'left': return input.moveX < 0 || (!!input.aimVec && input.aimVec.x < 0); + case 'right': return input.moveX > 0 || (!!input.aimVec && input.aimVec.x > 0); } } + +/** + * Округлить вектор прицеливания до ближайшего из 4 кардинальных направлений. + * Используется для `facing` (рендер спрайта игрока) и для случаев, когда + * логике нужен именно Dir (например, поставить дверь в комнату). + */ +export function cardinalFromVec(v: { x: number; y: number }): Dir { + if (Math.abs(v.x) > Math.abs(v.y)) return v.x > 0 ? 'right' : 'left'; + return v.y > 0 ? 'down' : 'up'; +} diff --git a/src/input/KeyboardController.ts b/src/input/KeyboardController.ts index 157b2a0..3291b69 100644 --- a/src/input/KeyboardController.ts +++ b/src/input/KeyboardController.ts @@ -1,5 +1,4 @@ import type { InputSource, InputState } from './InputState'; -import type { Dir } from '../core/types'; /** * Раскладка: WASD — движение, стрелки — прицельная стрельба, пробел — @@ -74,16 +73,20 @@ export class KeyboardController implements InputSource { if (down('KeyA')) moveX -= 1; if (down('KeyD')) moveX += 1; - let aimDir: Dir | null = null; - if (down('ArrowUp')) aimDir = 'up'; - else if (down('ArrowDown')) aimDir = 'down'; - else if (down('ArrowLeft')) aimDir = 'left'; - else if (down('ArrowRight')) aimDir = 'right'; + // Прицел собираем из стрелок как ВЕКТОР — поддерживает 8 направлений + // (одновременные ArrowUp + ArrowRight дают диагональ {0,-1}+{1,0}). + let aimX = 0; + let aimY = 0; + if (down('ArrowUp')) aimY -= 1; + if (down('ArrowDown')) aimY += 1; + if (down('ArrowLeft')) aimX -= 1; + if (down('ArrowRight')) aimX += 1; + const aimVec = (aimX !== 0 || aimY !== 0) ? { x: aimX, y: aimY } : null; const snapshot: InputState = { moveX, moveY, - aimDir, + aimVec, attackHeld: down('Space'), toggleWeapon: this.toggleWeaponEdge, restart: this.restartEdge, diff --git a/src/render/HudOverlay.ts b/src/render/HudOverlay.ts index 2f1a967..9c9c4ea 100644 --- a/src/render/HudOverlay.ts +++ b/src/render/HudOverlay.ts @@ -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); diff --git a/src/render/ThreeRenderer.ts b/src/render/ThreeRenderer.ts index 5088178..cbfbb54 100644 --- a/src/render/ThreeRenderer.ts +++ b/src/render/ThreeRenderer.ts @@ -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 = { 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(); diff --git a/tests/combat.test.ts b/tests/combat.test.ts new file mode 100644 index 0000000..809c893 --- /dev/null +++ b/tests/combat.test.ts @@ -0,0 +1,150 @@ +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 { + 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); + }); +}); diff --git a/tests/game.test.ts b/tests/game.test.ts index b88f3d9..1d98a9e 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -39,7 +39,7 @@ describe('Game', () => { it('стрельба создаёт снаряд, который потом исчезает', () => { const game = new Game(DEFAULT_RULES, new Rng(4)); - game.step(input({ aimDir: 'right' })); + game.step(input({ aimVec: { x: 1, y: 0 } })); expect(game.curRoom.tears.length).toBe(1); // Снаряд летит вправо и со временем гаснет (стена/время жизни). for (let i = 0; i < 200; i++) game.step(input()); @@ -158,7 +158,7 @@ describe('Game', () => { const game = new Game(DEFAULT_RULES, new Rng(12)); game.player.addWeapon('boomerang'); - game.step(input({ aimDir: 'right' })); + game.step(input({ aimVec: { x: 1, y: 0 } })); expect(game.curRoom.tears[0].type).toBe('boomerang'); expect(game.curRoom.tears[0].damage).toBe(2); @@ -171,7 +171,7 @@ describe('Game', () => { const enemy = new Enemy(game.player.x + 7, game.player.y, 'tank'); game.curRoom.enemies = [enemy]; - game.step(input({ aimDir: 'right' })); + game.step(input({ aimVec: { x: 1, y: 0 } })); expect(enemy.burnTimer).toBeGreaterThan(0); const hpAfterHit = enemy.hp; diff --git a/tests/items.test.ts b/tests/items.test.ts new file mode 100644 index 0000000..b74efca --- /dev/null +++ b/tests/items.test.ts @@ -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 { + 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); + }); +}); diff --git a/tests/movement.test.ts b/tests/movement.test.ts new file mode 100644 index 0000000..b7c2c3f --- /dev/null +++ b/tests/movement.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'bun:test'; +import { OX, OY, TILE, COLS, ROWS } from '../src/config'; +import { Room } from '../src/core/world/Room'; +import { moveEntity, type Movable } from '../src/core/systems/movement'; + +/** Минимальный movable, на котором удобно тестировать moveEntity. */ +function box(x: number, y: number, size = 20): Movable & { x: number; y: number } { + const obj: Movable & { x: number; y: number } = { + x, y, + get box() { + return { x: this.x - size / 2, y: this.y - size / 2, w: size, h: size }; + }, + }; + return obj; +} + +/** Свежая зачищенная комната: двери открыты, по периметру стены. */ +function makeRoom() { + const r = new Room(0, 0, 'spawn'); + r.cleared = true; + r.rebuildTiles(); + return r; +} + +describe('moveEntity', () => { + it('свободно двигается по полу', () => { + const room = makeRoom(); + const cx = OX + (COLS / 2) * TILE; + const cy = OY + (ROWS / 2) * TILE; + const e = box(cx, cy); + moveEntity(e, 10, 5, room); + expect(e.x).toBe(cx + 10); + expect(e.y).toBe(cy + 5); + }); + + it('не проходит сквозь стену по X, но Y применяется (скольжение)', () => { + const room = makeRoom(); + const x = OX + 3 * TILE; + const y = OY + (ROWS / 2) * TILE; + const e = box(x, y, 16); + // Большой рывок влево — хитбокс вылетит за col=0, isBlocked вернётся true. + moveEntity(e, -500, 10, room); + expect(e.x).toBe(x); // откатилось + expect(e.y).toBe(y + 10); // применилось + }); + + it('не проходит сквозь стену по Y, X применяется', () => { + const room = makeRoom(); + const x = OX + (COLS / 2) * TILE; + const y = OY + 3 * TILE; + const e = box(x, y, 16); + moveEntity(e, 7, -500, room); + expect(e.x).toBe(x + 7); + expect(e.y).toBe(y); + }); + + it('ни X, ни Y не применяются, если в углу', () => { + const room = makeRoom(); + const x = OX + 3 * TILE; + const y = OY + 3 * TILE; + const e = box(x, y, 16); + moveEntity(e, -200, -200, room); + expect(e.x).toBe(x); + expect(e.y).toBe(y); + }); +}); diff --git a/tests/projectiles.test.ts b/tests/projectiles.test.ts new file mode 100644 index 0000000..0911660 --- /dev/null +++ b/tests/projectiles.test.ts @@ -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); + }); +}); diff --git a/tests/regressions.test.ts b/tests/regressions.test.ts new file mode 100644 index 0000000..242c3b6 --- /dev/null +++ b/tests/regressions.test.ts @@ -0,0 +1,271 @@ +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 { Projectile } from '../src/core/entities/Projectile'; +import { emptyInput, type InputState } from '../src/input/InputState'; +import { DEFAULT_RULES, PRESETS } from '../src/core/rules'; +import { OX, OY, TILE, COLS, ROWS } from '../src/config'; + +function input(patch: Partial = {}): InputState { + return { ...emptyInput(), ...patch }; +} + +/** Ставит игру в normal-комнату с указанными врагами. */ +function placeInNormalRoom(game: Game, enemies: 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 = enemies; + room.cleared = false; + return; + } + throw new Error('normal-комната не найдена'); +} + +describe('Регрессии Wave 1', () => { + it('мёртвые враги удаляются из room.enemies (нет утечки)', () => { + const game = new Game(DEFAULT_RULES, new Rng(31)); + const cx = OX + (COLS / 2) * TILE; + const cy = OY + (ROWS / 2) * TILE; + // Три врага, по 1 hp — умрут почти сразу от попадания. + const e1 = new Enemy(cx - 50, cy, 'normal'); + const e2 = new Enemy(cx + 50, cy, 'normal'); + const e3 = new Enemy(cx, cy - 50, 'normal'); + e1.hp = e2.hp = e3.hp = 1; + placeInNormalRoom(game, [e1, e2, e3]); + + // Чтобы не полагаться на полёт снарядов — убиваем напрямую и прогоняем шаг. + e1.hp = 0; e2.hp = 0; e3.hp = 0; + game.step(input()); + + expect(game.curRoom.enemies.length).toBe(0); // фильтранулось, а не осталось 3 мёртвых + }); + + it('РЕГРЕССИЯ: после убийства всех врагов комната становится cleared (двери откроются)', () => { + const game = new Game(DEFAULT_RULES, new Rng(34)); + const cx = OX + (COLS / 2) * TILE; + const cy = OY + (ROWS / 2) * TILE; + const e = new Enemy(cx, cy, 'normal'); + placeInNormalRoom(game, [e]); + expect(game.curRoom.cleared).toBe(false); + + // Убиваем и прогоняем шаг — фильтр не должен помешать cleared стать true. + e.hp = 0; + game.step(input()); + + expect(game.curRoom.cleared).toBe(true); + expect(game.curRoom.enemies.length).toBe(0); + }); + + it('Player.addWeapon использует MODE_MELEE для melee-оружия', () => { + // Проверяем, что mode === MODE_MELEE (1), а не литерал 1 по ошибке. + const game = new Game(DEFAULT_RULES, new Rng(32)); + game.player.addWeapon('axe'); // melee + expect(game.player.mode).toBe(1); + game.player.addWeapon('tears'); // ranged + expect(game.player.mode).toBe(0); + }); + + it('equipSlot молча игнорирует несуществующие слоты', () => { + const game = new Game(DEFAULT_RULES, new Rng(33)); + const equippedBefore = game.player.equipped; + game.equipSlot(5); // не существует + expect(game.player.equipped).toBe(equippedBefore); + game.equipSlot(-1); + expect(game.player.equipped).toBe(equippedBefore); + }); + + it('WeaponDef.spread/beamRange вынесены из хардкода и читаются оружием', () => { + const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 999 }; + const game = new Game(endless); + game.player.addWeapon('laser'); + // Выстрел — луч должен оказаться на beamRange от игрока (70 по умолчанию). + const px = game.player.x; + game.step(input({ aimVec: { x: 1, y: 0 } })); + const beam = game.curRoom.tears.find((t) => t.type === 'beam'); + expect(beam).toBeDefined(); + expect(beam!.x).toBe(px + 70); // если не вынесли в WeaponDef — будет undefined → NaN + }); +}); + +describe('Босс (milestone, этаж 5/10/15)', () => { + it('босс не плодит миньёнов сверх BOSS.maxMinions', () => { + const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 505 }; + const game = new Game(endless); + + // Домотать до 5-го этажа. + for (let i = 2; i <= 5; i++) { + const bossRoom = [...game.roomMap.rooms.values()].find((r) => r.type === 'boss')!; + bossRoom.cleared = true; + game.step(input()); + } + expect(game.floor).toBe(5); + + // Заходим в комнату босса. + const bossRoom = [...game.roomMap.rooms.values()].find((r) => r.type === 'boss')!; + game.cc = bossRoom.c; + game.cr = bossRoom.r; + game.enterRoom('up'); + + const boss = bossRoom.enemies.find((e) => e.type === 'boss')!; + expect(boss).toBeDefined(); + + // Опускаем HP до фазы 3 (< 33%), чтобы начался спавн миньёнов. + boss.hp = Math.floor(boss.maxHp * 0.1); + boss.spawnTimer = 0; // принудительно вызываем спавн в ближайший шаг + + // Прогоняем много шагов, чтобы спавн многократно сработал. + for (let i = 0; i < 5000; i++) game.step(input()); + + const aliveEnemies = bossRoom.enemies.filter((e) => e.alive); + // Миньёны — все живые, кроме самого босса. + expect(aliveEnemies.length).toBeLessThanOrEqual(5); // босс + 4 миньёна максимум + }); +}); + +describe('Переход между комнатами', () => { + it('игрок зачищает комнату и переходит в соседнюю', () => { + const game = new Game(DEFAULT_RULES, new Rng(41)); + const spawnRoom = game.curRoom; + expect(spawnRoom.type).toBe('spawn'); + + // Берём первое доступное направление дверей у спавна. + const dir = (['up', 'down', 'left', 'right'] as const).find((d) => spawnRoom.doors[d]); + expect(dir).toBeDefined(); + + const cc0 = game.cc; + const cr0 = game.cr; + + // Ставим игрока ОДИН ТАЙЛ ВНУТРЬ от двери (как делает enterRoom): тогда по + // ходу движения к двери он несколько шагов проведёт в крайнем тайле и + // checkTransition успеет сработать. (Старт прямо в door row был бы сразу + // вытолкнут за пределы комнаты.) + const center = doorNeighborCell(dir!); + game.player.place(center.x, center.y); + game.player.transCD = 0; + + const move = { + up: { moveX: 0, moveY: -1 }, + down: { moveX: 0, moveY: 1 }, + left: { moveX: -1, moveY: 0 }, + right: { moveX: 1, moveY: 0 }, + }[dir!]; + + for (let i = 0; i < 80; i++) game.step(input(move)); + + expect(game.cc !== cc0 || game.cr !== cr0).toBe(true); + }); +}); + +/** Тайл ВНУТРИ комнаты напротив двери (один шаг от двери). */ +function doorNeighborCell(dir: 'up' | 'down' | 'left' | 'right'): { x: number; y: number } { + // Центр двери + один тайл внутрь. + const cx = 7.5; + const cy = 5.5; + switch (dir) { + case 'up': return { x: OX + cx * TILE, y: OY + 1.5 * TILE }; + case 'down': return { x: OX + cx * TILE, y: OY + (ROWS - 1.5) * TILE }; + case 'left': return { x: OX + 1.5 * TILE, y: OY + cy * TILE }; + case 'right': return { x: OX + (COLS - 1.5) * TILE, y: OY + cy * TILE }; + } +} + +describe('Сундук → пикап → экипировка', () => { + it('сундук после уничтожения выпадает пикап оружия, который подбирается', () => { + const game = new Game(DEFAULT_RULES, new Rng(51)); + // Найдём сокровищницу. + let treasure: typeof game.curRoom | null = null; + for (const room of game.roomMap.rooms.values()) { + if (room.type === 'treasure') { treasure = room; break; } + } + if (!treasure) return; // мягко пропускаем, если на этом seed нет + game.cc = treasure.c; + game.cr = treasure.r; + game.enterRoom('up'); + + const chest = game.curRoom.chest!; + const weaponBefore = game.player.weapons[game.player.equipped].id; + + // Убиваем сундук напрямую и прогоняем несколько шагов: должен заспавнить пикап. + chest.hp = 0; + for (let i = 0; i < 5; i++) game.step(input()); + expect(game.curRoom.pickup).not.toBeNull(); + + // Ставим игрока на пикап и прогоняем шаги — должен подобрать. + game.player.place(game.curRoom.pickup!.x, game.curRoom.pickup!.y); + for (let i = 0; i < 5; i++) game.step(input()); + + expect(game.curRoom.pickup).toBeNull(); + expect(game.player.weapons[game.player.equipped].id).not.toBe(weaponBefore); + }); +}); + +describe('Secret room (секретка)', () => { + it('спавнится на карте с шансом — среди 50 seed’ов хотя бы раз', () => { + let found = 0; + for (let seed = 1; seed <= 50; seed++) { + const game = new Game(DEFAULT_RULES, new Rng(seed)); + for (const room of game.roomMap.rooms.values()) { + if (room.type === 'secret') { found++; break; } + } + } + expect(found).toBeGreaterThan(0); + }); + + it('при первом входе даёт +1 max HP; при повторном — НЕ даёт', () => { + // Найдём seed с секреткой. + let seed = 1; + let game = new Game(DEFAULT_RULES, new Rng(seed)); + while (![...game.roomMap.rooms.values()].some(r => r.type === 'secret') && seed < 200) { + seed++; + game = new Game(DEFAULT_RULES, new Rng(seed)); + } + if (seed >= 200) return; // мягко пропускаем + + const secret = [...game.roomMap.rooms.values()].find(r => r.type === 'secret')!; + const maxHp0 = game.player.maxHp; + game.cc = secret.c; game.cr = secret.r; + game.enterRoom('up'); + expect(game.player.maxHp).toBe(maxHp0 + 1); + + // Выйдем и зайдём снова — бонуса быть не должно. + game.cc = 0; game.cr = 0; // возвращаемся в спавн + game.enterRoom('up'); + const maxHp1 = game.player.maxHp; + game.cc = secret.c; game.cr = secret.r; + game.enterRoom('up'); + expect(game.player.maxHp).toBe(maxHp1); + }); + + it('в секретке нет врагов (как в сокровищнице) — зачищается сразу', () => { + let seed = 1; + let game = new Game(DEFAULT_RULES, new Rng(seed)); + while (![...game.roomMap.rooms.values()].some(r => r.type === 'secret') && seed < 200) { + seed++; + game = new Game(DEFAULT_RULES, new Rng(seed)); + } + if (seed >= 200) return; + + const secret = [...game.roomMap.rooms.values()].find(r => r.type === 'secret')!; + game.cc = secret.c; game.cr = secret.r; + game.enterRoom('up'); + expect(game.curRoom.enemies.length).toBe(0); + expect(game.curRoom.cleared).toBe(true); + }); +}); + +/** Тайл ВНУТРИ комнаты напротив двери (один шаг от двери). */ +function doorNeighborCell(dir: 'up' | 'down' | 'left' | 'right'): { x: number; y: number } { + // Центр двери + один тайл внутрь. + const cx = 7.5; + const cy = 5.5; + switch (dir) { + case 'up': return { x: OX + cx * TILE, y: OY + 1.5 * TILE }; + case 'down': return { x: OX + cx * TILE, y: OY + (ROWS - 1.5) * TILE }; + case 'left': return { x: OX + 1.5 * TILE, y: OY + cy * TILE }; + case 'right': return { x: OX + (COLS - 1.5) * TILE, y: OY + cy * TILE }; + } +}