Fix new game plus progression issues

This commit is contained in:
2026-06-19 13:26:19 +03:00
parent 01c1f71159
commit e55666f51c
8 changed files with 116 additions and 23 deletions
+5
View File
@@ -190,6 +190,11 @@ LevelRules ──► Game(rules) ──► RoomMap(rng, rules) // размер
└──► spawnEnemies(..., rules) // число/тип/сила врагов (множители) └──► spawnEnemies(..., rules) // число/тип/сила врагов (множители)
``` ```
В бесконечном спуске выбранный `rules` остаётся базовым пресетом забега, а
`Game` хранит отдельный активный снимок правил текущего этажа. Новая карта и
спавн врагов должны брать именно активные правила этажа, иначе данжен растёт, но
враги остаются с балансом первого этажа.
Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в
`config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`. `config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`.
`config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов). `config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов).
+50 -18
View File
@@ -14,6 +14,7 @@ import type { Room } from './world/Room';
import { collidesWall } from './systems/collision'; import { collidesWall } from './systems/collision';
import { spawnEnemies, spawnChest, pickChestWeapon } from './systems/spawner'; import { spawnEnemies, spawnChest, pickChestWeapon } from './systems/spawner';
import { WeaponPickup } from './entities/WeaponPickup'; import { WeaponPickup } from './entities/WeaponPickup';
import type { WeaponDef } from './weapons';
import { DEFAULT_RULES, scaleRulesForFloor, type LevelRules } from './rules'; import { DEFAULT_RULES, scaleRulesForFloor, type LevelRules } from './rules';
import type { InputState } from '../input/InputState'; import type { InputState } from '../input/InputState';
import { pressingDir } from '../input/InputState'; import { pressingDir } from '../input/InputState';
@@ -30,6 +31,7 @@ import { pressingDir } from '../input/InputState';
*/ */
export class Game { export class Game {
readonly rules: LevelRules; readonly rules: LevelRules;
private floorRules: LevelRules;
rng: Rng; // пересоздаётся в reset() — для воспроизводимости фикс-сида rng: Rng; // пересоздаётся в reset() — для воспроизводимости фикс-сида
roomMap: RoomMap; roomMap: RoomMap;
player: Player; player: Player;
@@ -40,6 +42,7 @@ export class Game {
won = false; won = false;
floor = 1; floor = 1;
inventoryOpen = false; inventoryOpen = false;
elapsedSteps = 0;
/** /**
* @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт». * @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт».
@@ -47,10 +50,11 @@ export class Game {
*/ */
constructor(rules: LevelRules = DEFAULT_RULES, rng?: Rng) { constructor(rules: LevelRules = DEFAULT_RULES, rng?: Rng) {
this.rules = rules; this.rules = rules;
this.floorRules = rules;
this.rng = rng ?? new Rng(rules.seed); this.rng = rng ?? new Rng(rules.seed);
this.player = new Player(rules.player); this.player = new Player(rules.player);
this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE; 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'); this.enterRoom('up');
} }
@@ -85,10 +89,17 @@ export class Game {
this.inventoryOpen = false; this.inventoryOpen = false;
} }
/** Закрыть инвентарь без прямой мутации поля снаружи Game. */
closeInventory(): void {
this.inventoryOpen = false;
}
/** Один фиксированный шаг симуляции (= 1/60 c). */ /** Один фиксированный шаг симуляции (= 1/60 c). */
step(input: InputState): void { step(input: InputState): void {
if (this.gameOver || this.won || this.inventoryOpen) return; if (this.gameOver || this.won || this.inventoryOpen) return;
this.elapsedSteps++;
const room = this.curRoom; const room = this.curRoom;
const p = this.player; const p = this.player;
@@ -126,10 +137,14 @@ export class Game {
this.gameOver = false; this.gameOver = false;
this.won = false; this.won = false;
this.floor = 1; this.floor = 1;
this.inventoryOpen = false;
this.elapsedSteps = 0;
this.floorRules = this.rules;
// Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз. // Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз.
this.rng = new Rng(this.rules.seed); 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 = new Player(this.rules.player);
this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
this.cc = 0; this.cc = 0;
this.cr = 0; this.cr = 0;
this.meleeSwing = null; this.meleeSwing = null;
@@ -157,7 +172,7 @@ export class Game {
room.tears = []; room.tears = [];
if (!room.cleared && room.type !== 'spawn') { 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) { if (room.type === 'treasure' && !room.chest) {
room.chest = spawnChest(room, this.rng); room.chest = spawnChest(room, this.rng);
@@ -218,19 +233,26 @@ export class Game {
t.speed = 0; t.speed = 0;
t.life = w.beamLife ?? 10; t.life = w.beamLife ?? 10;
t.damage = w.beamTickDmg ?? 2; t.damage = w.beamTickDmg ?? 2;
t.beamRadius = w.beamRadius ?? 44;
room.tears.push(t); room.tears.push(t);
} else if (w.spreadCount && w.spreadCount > 1) { } else if (w.spreadCount && w.spreadCount > 1) {
const spread = 0.15; const spread = 0.15;
const perpX = -ny; const perpX = -ny;
const perpY = nx; const perpY = nx;
const projectileType = w.projectileType ?? 'tear';
for (let i = 0; i < w.spreadCount; i++) { for (let i = 0; i < w.spreadCount; i++) {
const off = (i - (w.spreadCount - 1) / 2) * spread; const off = (i - (w.spreadCount - 1) / 2) * spread;
const sx = nx + perpX * off; const sx = nx + perpX * off;
const sy = ny + perpY * 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 { } 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 { } else {
this.meleeSwing = new MeleeSwing(p.x, p.y, dir, { 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 { private updateTears(room: Room): void {
for (const t of room.tears) { for (const t of room.tears) {
if (!t.alive) continue; if (!t.alive) continue;
@@ -272,7 +303,7 @@ export class Game {
t.life--; t.life--;
if (t.life <= 0) continue; if (t.life <= 0) continue;
if (t.life % 2 === 0) { if (t.life % 2 === 0) {
const radius = this.player.currentWeapon.beamRadius ?? 44; const radius = t.beamRadius || 44;
for (const e of room.enemies) { for (const e of room.enemies) {
if (!e.alive) continue; if (!e.alive) continue;
if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + radius) { 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.hp -= t.damage;
e.hitTimer = ENEMY.hitFlash; e.hitTimer = ENEMY.hitFlash;
if (t.type === 'fireball') { if (t.type === 'fireball') {
const w = this.player.currentWeapon; e.burnTimer = t.burnDuration;
e.burnTimer = w.fireDuration ?? 0; e.burnDamage = t.burnDamage;
e.burnInterval = t.burnInterval;
} }
if (t.type !== 'laser') { if (t.type !== 'laser') {
t.life = 0; t.life = 0;
@@ -351,8 +383,7 @@ export class Game {
/** Взрыв бомбы: AoE-урон по врагам. */ /** Взрыв бомбы: AoE-урон по врагам. */
private explodeBomb(room: Room, t: Projectile): void { private explodeBomb(room: Room, t: Projectile): void {
if (t.type !== 'bomb') return; if (t.type !== 'bomb') return;
const w = this.player.currentWeapon; const radius = t.explosionRadius || 60;
const radius = w.explosionRadius ?? 60;
for (const e of room.enemies) { for (const e of room.enemies) {
if (!e.alive) continue; if (!e.alive) continue;
if (dist(t.x, t.y, e.x, e.y) < radius) { if (dist(t.x, t.y, e.x, e.y) < radius) {
@@ -392,19 +423,19 @@ export class Game {
for (const e of room.enemies) { for (const e of room.enemies) {
if (!e.alive) continue; if (!e.alive) continue;
aliveCount++;
if (e.hitTimer > 0) e.hitTimer--; if (e.hitTimer > 0) e.hitTimer--;
// Горение: урон каждые fireInterval тиков. // Горение: урон каждые fireInterval тиков.
if (e.burnTimer > 0) { if (e.burnTimer > 0) {
e.burnTimer--; e.burnTimer--;
const staff = this.player.weapons.find((w) => w.id === 'staff'); if (e.burnTimer % e.burnInterval === 0) {
if (staff && e.burnTimer % (staff.fireInterval ?? 10) === 0) { e.hp -= e.burnDamage;
e.hp--;
e.hitTimer = ENEMY.hitFlash; e.hitTimer = ENEMY.hitFlash;
} }
} }
if (!e.alive) continue;
aliveCount++;
// Фаза отбрасывания: летит по инерции, ИИ не работает. // Фаза отбрасывания: летит по инерции, ИИ не работает.
if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) { if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) {
@@ -419,7 +450,7 @@ export class Game {
e.knx = 0; e.knx = 0;
e.kny = 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); this.updateMilestoneBoss(e, room, p, newEnemies);
} else if (e.type === 'shooter') { } else if (e.type === 'shooter') {
this.updateShooter(e, room, p); this.updateShooter(e, room, p);
@@ -507,7 +538,8 @@ export class Game {
const rng = this.rng; const rng = this.rng;
const mx = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE; const mx = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
const my = OY + 2 * TILE + rng.float(0, ROWS - 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/оружие сохраняются. */ /** Спуск на следующий этаж: новая карта, усиленные враги, HP/оружие сохраняются. */
private descend(): void { private descend(): void {
this.floor++; this.floor++;
const rules = scaleRulesForFloor(this.rules, this.floor); this.floorRules = scaleRulesForFloor(this.rules, this.floor);
const p = this.player; const p = this.player;
const savedHp = p.hp; const savedHp = p.hp;
@@ -624,7 +656,7 @@ export class Game {
const floorSeed = this.rules.seed !== undefined ? this.rules.seed + this.floor : undefined; const floorSeed = this.rules.seed !== undefined ? this.rules.seed + this.floor : undefined;
this.rng = floorSeed !== undefined ? new Rng(floorSeed) : new Rng(); 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.cc = 0;
this.cr = 0; this.cr = 0;
this.meleeSwing = null; this.meleeSwing = null;
+2
View File
@@ -23,6 +23,8 @@ export class Enemy {
hitTimer = 0; // мигание при попадании (шаги) hitTimer = 0; // мигание при попадании (шаги)
atkTimer = 0; // перезарядка контактного удара (шаги) atkTimer = 0; // перезарядка контактного удара (шаги)
burnTimer = 0; // тиков до конца горения (0 = не горит) burnTimer = 0; // тиков до конца горения (0 = не горит)
burnDamage = 1; // урон за тик горения
burnInterval = 10; // как часто горение наносит урон
chargeTimer = 0; // перезарядка рывка для charger (шаги) chargeTimer = 0; // перезарядка рывка для charger (шаги)
shootTimer = 0; // перезарядка стрельбы для shooter (шаги) shootTimer = 0; // перезарядка стрельбы для shooter (шаги)
phase = 1; // фаза босса (мультифазные боссы на этажах 5/10/15) phase = 1; // фаза босса (мультифазные боссы на этажах 5/10/15)
+2
View File
@@ -5,6 +5,7 @@ import type { Dir, Box } from '../types';
export class MeleeSwing { export class MeleeSwing {
readonly dir: Dir; readonly dir: Dir;
life = MELEE.life; life = MELEE.life;
readonly maxLife: number;
readonly damage: number; readonly damage: number;
readonly kb: number; readonly kb: number;
readonly box: Box; readonly box: Box;
@@ -14,6 +15,7 @@ export class MeleeSwing {
this.damage = overrides?.damage ?? MELEE.damage; this.damage = overrides?.damage ?? MELEE.damage;
this.kb = overrides?.knockback ?? MELEE.knockback; this.kb = overrides?.knockback ?? MELEE.knockback;
if (overrides?.life !== undefined) this.life = overrides.life; if (overrides?.life !== undefined) this.life = overrides.life;
this.maxLife = this.life;
const { reach: d } = MELEE; const { reach: d } = MELEE;
const size = MELEE.size * (overrides?.sizeMul ?? 1); const size = MELEE.size * (overrides?.sizeMul ?? 1);
const [dx, dy] = DIR[dir]; const [dx, dy] = DIR[dir];
+5
View File
@@ -15,6 +15,11 @@ export class Projectile {
damage = PROJECTILE.damage; damage = PROJECTILE.damage;
life = PROJECTILE.life; life = PROJECTILE.life;
hostile = false; // true = вражеский снаряд, бьёт игрока 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') { constructor(x: number, y: number, dx: number, dy: number, type: ProjectileType = 'tear') {
this.x = this.prevX = x; this.x = this.prevX = x;
+1 -1
View File
@@ -60,7 +60,7 @@ function boot(): void {
if (e.code === 'Escape' && loop) { if (e.code === 'Escape' && loop) {
const g = (window as Window & { game?: Game }).game; const g = (window as Window & { game?: Game }).game;
if (g?.inventoryOpen) { if (g?.inventoryOpen) {
g.inventoryOpen = false; g.closeInventory();
e.preventDefault(); e.preventDefault();
return; return;
} }
+4 -4
View File
@@ -137,7 +137,7 @@ export class ThreeRenderer implements Renderer {
this.syncTears(room, alpha); this.syncTears(room, alpha);
this.syncSwing(game); this.syncSwing(game);
this.syncChest(room); this.syncChest(room);
this.syncPickup(room); this.syncPickup(room, game.elapsedSteps + alpha);
this.updateEffects(); this.updateEffects();
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
@@ -319,7 +319,7 @@ export class ThreeRenderer implements Renderer {
this.swingMesh.visible = true; 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.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.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 { 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) { if (room.pickup) {
const wid = room.pickup.weaponId; const wid = room.pickup.weaponId;
if (!this.pickupMesh || this.currentPickupWeapon !== wid) { if (!this.pickupMesh || this.currentPickupWeapon !== wid) {
@@ -362,7 +362,7 @@ export class ThreeRenderer implements Renderer {
const w = p.w * 1.6; const w = p.w * 1.6;
const h = w * (48 / 48); const h = w * (48 / 48);
this.pickupMesh.scale.set(w, h, 1); 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) { } else if (this.pickupMesh) {
this.scene.remove(this.pickupMesh); this.scene.remove(this.pickupMesh);
(this.pickupMesh.material as THREE.Material).dispose(); (this.pickupMesh.material as THREE.Material).dispose();
+47
View File
@@ -133,4 +133,51 @@ describe('Game', () => {
expect(game.cc).toBe(0); expect(game.cc).toBe(0);
expect(game.cr).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);
});
}); });