From e55666f51cdd8a0b3c71d6e2e0dc43fe291c4f13 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Fri, 19 Jun 2026 13:26:19 +0300 Subject: [PATCH] Fix new game plus progression issues --- docs/ARCHITECTURE.md | 5 +++ src/core/Game.ts | 68 ++++++++++++++++++++++++--------- src/core/entities/Enemy.ts | 2 + src/core/entities/MeleeSwing.ts | 2 + src/core/entities/Projectile.ts | 5 +++ src/main.ts | 2 +- src/render/ThreeRenderer.ts | 8 ++-- tests/game.test.ts | 47 +++++++++++++++++++++++ 8 files changed, 116 insertions(+), 23 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6f741d7..e5383b7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -190,6 +190,11 @@ LevelRules ──► Game(rules) ──► RoomMap(rng, rules) // размер └──► spawnEnemies(..., rules) // число/тип/сила врагов (множители) ``` +В бесконечном спуске выбранный `rules` остаётся базовым пресетом забега, а +`Game` хранит отдельный активный снимок правил текущего этажа. Новая карта и +спавн врагов должны брать именно активные правила этажа, иначе данжен растёт, но +враги остаются с балансом первого этажа. + Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в `config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`. `config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов). diff --git a/src/core/Game.ts b/src/core/Game.ts index 34dcfce..b4003fc 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -14,6 +14,7 @@ import type { Room } from './world/Room'; import { collidesWall } from './systems/collision'; import { spawnEnemies, spawnChest, pickChestWeapon } from './systems/spawner'; import { WeaponPickup } from './entities/WeaponPickup'; +import type { WeaponDef } from './weapons'; import { DEFAULT_RULES, scaleRulesForFloor, type LevelRules } from './rules'; import type { InputState } from '../input/InputState'; import { pressingDir } from '../input/InputState'; @@ -30,6 +31,7 @@ import { pressingDir } from '../input/InputState'; */ export class Game { readonly rules: LevelRules; + private floorRules: LevelRules; rng: Rng; // пересоздаётся в reset() — для воспроизводимости фикс-сида roomMap: RoomMap; player: Player; @@ -40,6 +42,7 @@ export class Game { won = false; floor = 1; inventoryOpen = false; + elapsedSteps = 0; /** * @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт». @@ -47,10 +50,11 @@ export class Game { */ constructor(rules: LevelRules = DEFAULT_RULES, rng?: Rng) { this.rules = rules; + this.floorRules = rules; this.rng = rng ?? new Rng(rules.seed); this.player = new Player(rules.player); this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE; - this.roomMap = new RoomMap(this.rng, rules); + this.roomMap = new RoomMap(this.rng, this.floorRules); this.enterRoom('up'); } @@ -85,10 +89,17 @@ export class Game { this.inventoryOpen = false; } + /** Закрыть инвентарь без прямой мутации поля снаружи Game. */ + closeInventory(): void { + this.inventoryOpen = false; + } + /** Один фиксированный шаг симуляции (= 1/60 c). */ step(input: InputState): void { if (this.gameOver || this.won || this.inventoryOpen) return; + this.elapsedSteps++; + const room = this.curRoom; const p = this.player; @@ -126,10 +137,14 @@ export class Game { this.gameOver = false; this.won = false; this.floor = 1; + this.inventoryOpen = false; + this.elapsedSteps = 0; + this.floorRules = this.rules; // Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз. this.rng = new Rng(this.rules.seed); - this.roomMap = new RoomMap(this.rng, this.rules); + this.roomMap = new RoomMap(this.rng, this.floorRules); this.player = new Player(this.rules.player); + this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE; this.cc = 0; this.cr = 0; this.meleeSwing = null; @@ -157,7 +172,7 @@ export class Game { room.tears = []; if (!room.cleared && room.type !== 'spawn') { - room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.rules); + room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.floorRules); // Сундук в сокровищнице. if (room.type === 'treasure' && !room.chest) { room.chest = spawnChest(room, this.rng); @@ -218,19 +233,26 @@ export class Game { t.speed = 0; t.life = w.beamLife ?? 10; t.damage = w.beamTickDmg ?? 2; + t.beamRadius = w.beamRadius ?? 44; room.tears.push(t); } else if (w.spreadCount && w.spreadCount > 1) { const spread = 0.15; const perpX = -ny; const perpY = nx; + const projectileType = w.projectileType ?? 'tear'; for (let i = 0; i < w.spreadCount; i++) { const off = (i - (w.spreadCount - 1) / 2) * spread; const sx = nx + perpX * off; const sy = ny + perpY * off; - room.tears.push(new Projectile(p.x, p.y, sx, sy, w.projectileType)); + const len = Math.hypot(sx, sy) || 1; + const t = new Projectile(p.x, p.y, sx / len, sy / len, projectileType); + this.applyWeaponProjectileStats(t, w); + room.tears.push(t); } } else { - room.tears.push(new Projectile(p.x, p.y, nx, ny, w.projectileType)); + const t = new Projectile(p.x, p.y, nx, ny, w.projectileType ?? 'tear'); + this.applyWeaponProjectileStats(t, w); + room.tears.push(t); } } else { this.meleeSwing = new MeleeSwing(p.x, p.y, dir, { @@ -263,6 +285,15 @@ 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; @@ -272,7 +303,7 @@ export class Game { t.life--; if (t.life <= 0) continue; if (t.life % 2 === 0) { - const radius = this.player.currentWeapon.beamRadius ?? 44; + const radius = t.beamRadius || 44; for (const e of room.enemies) { if (!e.alive) continue; if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + radius) { @@ -325,8 +356,9 @@ export class Game { e.hp -= t.damage; e.hitTimer = ENEMY.hitFlash; if (t.type === 'fireball') { - const w = this.player.currentWeapon; - e.burnTimer = w.fireDuration ?? 0; + e.burnTimer = t.burnDuration; + e.burnDamage = t.burnDamage; + e.burnInterval = t.burnInterval; } if (t.type !== 'laser') { t.life = 0; @@ -351,8 +383,7 @@ export class Game { /** Взрыв бомбы: AoE-урон по врагам. */ private explodeBomb(room: Room, t: Projectile): void { if (t.type !== 'bomb') return; - const w = this.player.currentWeapon; - const radius = w.explosionRadius ?? 60; + 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) { @@ -392,19 +423,19 @@ export class Game { for (const e of room.enemies) { if (!e.alive) continue; - aliveCount++; if (e.hitTimer > 0) e.hitTimer--; // Горение: урон каждые fireInterval тиков. if (e.burnTimer > 0) { e.burnTimer--; - const staff = this.player.weapons.find((w) => w.id === 'staff'); - if (staff && e.burnTimer % (staff.fireInterval ?? 10) === 0) { - e.hp--; + if (e.burnTimer % e.burnInterval === 0) { + e.hp -= e.burnDamage; e.hitTimer = ENEMY.hitFlash; } } + if (!e.alive) continue; + aliveCount++; // Фаза отбрасывания: летит по инерции, ИИ не работает. if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) { @@ -419,7 +450,7 @@ export class Game { e.knx = 0; e.kny = 0; - if (e.type === 'boss' && this.milestoneBossFloor(this.floor)) { + if (e.type === 'boss' && this.milestoneBossFloor(this.floor)) { this.updateMilestoneBoss(e, room, p, newEnemies); } else if (e.type === 'shooter') { this.updateShooter(e, room, p); @@ -507,7 +538,8 @@ export class Game { 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; - newEnemies.push(new Enemy(mx, my, 'fast', { hpMul: 1.5, speedMul: 1.2 })); + const er = this.floorRules.enemies; + newEnemies.push(new Enemy(mx, my, 'fast', { hpMul: er.hpMul * 1.5, speedMul: er.speedMul * 1.2 })); } } } @@ -615,7 +647,7 @@ export class Game { /** Спуск на следующий этаж: новая карта, усиленные враги, HP/оружие сохраняются. */ private descend(): void { this.floor++; - const rules = scaleRulesForFloor(this.rules, this.floor); + this.floorRules = scaleRulesForFloor(this.rules, this.floor); const p = this.player; const savedHp = p.hp; @@ -624,7 +656,7 @@ export class Game { const floorSeed = this.rules.seed !== undefined ? this.rules.seed + this.floor : undefined; this.rng = floorSeed !== undefined ? new Rng(floorSeed) : new Rng(); - this.roomMap = new RoomMap(this.rng, rules); + this.roomMap = new RoomMap(this.rng, this.floorRules); this.cc = 0; this.cr = 0; this.meleeSwing = null; diff --git a/src/core/entities/Enemy.ts b/src/core/entities/Enemy.ts index d5605fa..0897a8e 100644 --- a/src/core/entities/Enemy.ts +++ b/src/core/entities/Enemy.ts @@ -23,6 +23,8 @@ export class Enemy { hitTimer = 0; // мигание при попадании (шаги) atkTimer = 0; // перезарядка контактного удара (шаги) burnTimer = 0; // тиков до конца горения (0 = не горит) + burnDamage = 1; // урон за тик горения + burnInterval = 10; // как часто горение наносит урон chargeTimer = 0; // перезарядка рывка для charger (шаги) shootTimer = 0; // перезарядка стрельбы для shooter (шаги) phase = 1; // фаза босса (мультифазные боссы на этажах 5/10/15) diff --git a/src/core/entities/MeleeSwing.ts b/src/core/entities/MeleeSwing.ts index 65de91d..b95ae53 100644 --- a/src/core/entities/MeleeSwing.ts +++ b/src/core/entities/MeleeSwing.ts @@ -5,6 +5,7 @@ import type { Dir, Box } from '../types'; export class MeleeSwing { readonly dir: Dir; life = MELEE.life; + readonly maxLife: number; readonly damage: number; readonly kb: number; readonly box: Box; @@ -14,6 +15,7 @@ export class MeleeSwing { this.damage = overrides?.damage ?? MELEE.damage; this.kb = overrides?.knockback ?? MELEE.knockback; if (overrides?.life !== undefined) this.life = overrides.life; + this.maxLife = this.life; const { reach: d } = MELEE; const size = MELEE.size * (overrides?.sizeMul ?? 1); const [dx, dy] = DIR[dir]; diff --git a/src/core/entities/Projectile.ts b/src/core/entities/Projectile.ts index 296f437..8cbd0b1 100644 --- a/src/core/entities/Projectile.ts +++ b/src/core/entities/Projectile.ts @@ -15,6 +15,11 @@ export class Projectile { damage = PROJECTILE.damage; life = PROJECTILE.life; hostile = false; // true = вражеский снаряд, бьёт игрока + burnDuration = 0; + burnDamage = 1; + burnInterval = 10; + explosionRadius = 0; + beamRadius = 0; constructor(x: number, y: number, dx: number, dy: number, type: ProjectileType = 'tear') { this.x = this.prevX = x; diff --git a/src/main.ts b/src/main.ts index 470f8b0..d907812 100644 --- a/src/main.ts +++ b/src/main.ts @@ -60,7 +60,7 @@ function boot(): void { if (e.code === 'Escape' && loop) { const g = (window as Window & { game?: Game }).game; if (g?.inventoryOpen) { - g.inventoryOpen = false; + g.closeInventory(); e.preventDefault(); return; } diff --git a/src/render/ThreeRenderer.ts b/src/render/ThreeRenderer.ts index 42e57cb..8319fe5 100644 --- a/src/render/ThreeRenderer.ts +++ b/src/render/ThreeRenderer.ts @@ -137,7 +137,7 @@ export class ThreeRenderer implements Renderer { this.syncTears(room, alpha); this.syncSwing(game); this.syncChest(room); - this.syncPickup(room); + this.syncPickup(room, game.elapsedSteps + alpha); this.updateEffects(); this.renderer.render(this.scene, this.camera); @@ -319,7 +319,7 @@ export class ThreeRenderer implements Renderer { this.swingMesh.visible = true; this.swingMesh.position.set(s.box.x + s.box.w / 2, 2, s.box.y + s.box.h / 2); this.swingMesh.scale.set(s.box.w * 1.4, s.box.h * 1.4, 1); - (this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / MELEE.life); + (this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / s.maxLife); } private syncChest(room: Room): void { @@ -343,7 +343,7 @@ export class ThreeRenderer implements Renderer { } } - private syncPickup(room: Room): void { + private syncPickup(room: Room, visualStep: number): void { if (room.pickup) { const wid = room.pickup.weaponId; if (!this.pickupMesh || this.currentPickupWeapon !== wid) { @@ -362,7 +362,7 @@ export class ThreeRenderer implements Renderer { const w = p.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(Date.now() / 200) * 3, p.y); + this.pickupMesh.position.set(p.x, h / 2 + Math.sin(visualStep / 12) * 3, p.y); } else if (this.pickupMesh) { this.scene.remove(this.pickupMesh); (this.pickupMesh.material as THREE.Material).dispose(); diff --git a/tests/game.test.ts b/tests/game.test.ts index a70dbcf..b88f3d9 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -133,4 +133,51 @@ describe('Game', () => { expect(game.cc).toBe(0); expect(game.cr).toBe(0); }); + + it('РЕГРЕССИЯ: бесконечный спуск применяет усиление врагов при спавне комнат', () => { + const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 100 }; + const game = new Game(endless); + + for (let targetFloor = 2; targetFloor <= 5; targetFloor++) { + const bossRoom = [...game.roomMap.rooms.values()].find((room) => room.type === 'boss')!; + bossRoom.cleared = true; + game.step(input()); + expect(game.floor).toBe(targetFloor); + } + + const bossRoom = [...game.roomMap.rooms.values()].find((room) => room.type === 'boss')!; + game.cc = bossRoom.c; + game.cr = bossRoom.r; + game.enterRoom('up'); + + const boss = bossRoom.enemies.find((e) => e.type === 'boss')!; + expect(boss.maxHp).toBeGreaterThan(10); + }); + + it('снаряд берёт урон из выбранного оружия', () => { + const game = new Game(DEFAULT_RULES, new Rng(12)); + game.player.addWeapon('boomerang'); + + game.step(input({ aimDir: 'right' })); + + expect(game.curRoom.tears[0].type).toBe('boomerang'); + expect(game.curRoom.tears[0].damage).toBe(2); + }); + + it('РЕГРЕССИЯ: эффект снаряда не зависит от смены оружия после выстрела', () => { + const game = new Game(DEFAULT_RULES, new Rng(13)); + game.player.addWeapon('staff'); + + const enemy = new Enemy(game.player.x + 7, game.player.y, 'tank'); + game.curRoom.enemies = [enemy]; + + game.step(input({ aimDir: 'right' })); + expect(enemy.burnTimer).toBeGreaterThan(0); + + const hpAfterHit = enemy.hp; + game.player.addWeapon('tears'); + for (let i = 0; i < 10; i++) game.step(input()); + + expect(enemy.hp).toBeLessThan(hpAfterHit); + }); });