refactor: комплексный рефакторинг + Isaac-like геймплей

Баги и гигиена (Wave 1):
- Player.addWeapon использует MODE_MELEE вместо литерала 1
- Фильтрация мёртвых врагов из room.enemies (раньше массив рос в долгих боях)
- Кап миньёнов босса (BOSS.maxMinions=4) — иначе комната могла не зачиститься
- aliveCount корректно считает живых в knockback-фазе
- equipSlot валидирует диапазон слота
- Магические числа range=70/spread=0.15 вынесены в WeaponDef (beamRange/spread)
- Регрессия cleared-flag: после фильтрации массива длина 0, но cleared должен стать true

Распил Game.ts 683→465 строк (Wave 2):
- systems/movement.ts: moveEntity(e, dx, dy, room) — убрал 5 копий коллизионного паттерна
- systems/projectiles.ts: applyWeaponProjectileStats, explodeBomb, projectileHitWall
- systems/ai.ts: runAI через диспетчер-таблицу (вместо if/else-if каскада)
- Переходы/этажи оставлены в Game.ts (тесно завязаны на cc/cr/player)

Тесты 33→72 (Wave 3):
- movement.test.ts, projectiles.test.ts, combat.test.ts, regressions.test.ts, items.test.ts
- Покрытие: ближний/дальний бой, огнемёт, бомба, лазер, splitter, сундук→пикап,
  переход комнат, фазы босса + кап миньёнов, фильтрация мёртвых, секретка

Isaac-like геймплей (Wave 4):
- Статы игрока: damageMul/fireRateMul/rangeMul/shotSpeedMul (мультипликативно
  поверх WeaponDef), effectiveDamage/Cooldown, отображение в HUD
- 8-направленный прицел: aimDir: Dir → aimVec: {x,y}, стрелки дают диагонали
- Пассивные предметы (items.ts, 6 штук): сундук дропает 50/50 оружие/предмет
- Новый тип врага splitter: при смерти распадается на двух fast
- Новый тип комнаты secret: +1 max HP один раз при первом входе
This commit is contained in:
2026-06-19 16:50:15 +03:00
parent 5d676b7d95
commit a72172df72
25 changed files with 1346 additions and 293 deletions
+3 -2
View File
@@ -67,10 +67,11 @@
## Ввод (input/)
`InputState` — снимок намерений: оси движения, направление прицела, флаги
`InputState` — снимок намерений: оси движения, вектор прицела, флаги
удержания и однократные «edge»-действия. Делится на:
- **удерживаемые** (`moveX/Y`, `aimDir`, `attackHeld`) — читаются каждый шаг;
- **удерживаемые** (`moveX/Y`, `aimVec`, `attackHeld`) — читаются каждый шаг;
`aimVec` — вектор из стрелок, поддерживает 8 направлений (вкл. диагонали);
- **однократные** (`toggleWeapon`, `restart`) — срабатывают один раз на нажатие;
поэтому они обрабатываются в `consumeActions()` раз в кадр, а не в `step()`.
+10
View File
@@ -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)
};
+111 -231
View File
@@ -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 {
+48
View File
@@ -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;
+50 -2
View File
@@ -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);
}
/** Поставить позицию мгновенно, сбросив интерполяцию (телепорт). */
-20
View File
@@ -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 };
}
}
+78
View File
@@ -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<PlayerStats>;
/** Сколько добавить к максимальному HP (и текущему). */
maxHpBonus?: number;
}
export const ITEMS: Record<ItemId, ItemDef> = {
'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);
}
+171
View File
@@ -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 }));
}
}
}
+26
View File
@@ -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;
}
+65
View File
@@ -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;
+4 -3
View File
@@ -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];
+2 -2
View File
@@ -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) для коллизий. */
+8 -2
View File
@@ -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<WeaponId, WeaponDef> = {
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,
},
};
+2 -2
View File
@@ -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) {
+2
View File
@@ -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);
+26 -8
View File
@@ -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';
}
+10 -7
View File
@@ -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,
+15 -2
View File
@@ -67,6 +67,19 @@ export class HudOverlay implements Renderer {
const floorLabel = game.rules.endless ? ` | Этаж ${game.floor}` : '';
ctx.fillText(`${game.rules.name}${floorLabel}`, 20, healthBottom + 14);
// Активные множители стат игрока (от предметов). Показываем только
// отличные от нейтральных — чтобы не засорять UI в начале забега.
const s = p.stats;
const bits: string[] = [];
if (s.damageMul !== 1) bits.push(`DMG ×${s.damageMul.toFixed(2)}`);
if (s.fireRateMul !== 1) bits.push(`RATE ×${s.fireRateMul.toFixed(2)}`);
if (s.rangeMul !== 1) bits.push(`RNG ×${s.rangeMul.toFixed(2)}`);
if (s.shotSpeedMul !== 1) bits.push(`SPD ×${s.shotSpeedMul.toFixed(2)}`);
if (bits.length) {
ctx.fillStyle = '#9a7'; ctx.font = '10px monospace';
ctx.fillText(bits.join(' '), 20, healthBottom + 28);
}
// Индикатор оружия.
const my = CH - 46;
const w = p.currentWeapon;
@@ -92,7 +105,7 @@ export class HudOverlay implements Renderer {
// Подпись типа комнаты.
if (room.visited) {
const label = { spawn: 'СТАРТ', normal: '', treasure: 'СОКРОВИЩЕ', boss: 'БОСС' }[room.type];
const label = { spawn: 'СТАРТ', normal: '', treasure: 'СОКРОВИЩЕ', boss: 'БОСС', secret: 'СЕКРЕТ' }[room.type];
if (label) {
ctx.textAlign = 'right'; ctx.fillStyle = '#555'; ctx.font = '11px monospace';
ctx.fillText(label, CW - 20, OY + RH + 30);
@@ -145,7 +158,7 @@ export class HudOverlay implements Renderer {
let color = '#141414';
if (room.visited) {
color = { spawn: '#2a5a2a', boss: '#5a1a1a', treasure: '#5a5a1a', normal: '#555' }[room.type];
color = { spawn: '#2a5a2a', boss: '#5a1a1a', treasure: '#5a5a1a', secret: '#1a3a5a', normal: '#555' }[room.type];
}
ctx.fillStyle = color; ctx.fillRect(x, y, cell, cell);
+15 -9
View File
@@ -8,7 +8,6 @@ import type { Game } from '../core/Game';
import type { Room } from '../core/world/Room';
import type { Enemy } from '../core/entities/Enemy';
import type { Projectile } from '../core/entities/Projectile';
import type { WeaponId } from '../core/weapons';
import type { Renderer } from './Renderer';
import { DEFAULT_THEME, type Theme } from './theme';
import { Assets, type SpriteKey } from './assets';
@@ -55,6 +54,7 @@ export class ThreeRenderer implements Renderer {
private readonly enemyMatKey: Record<Enemy['type'], SpriteKey> = {
normal: 'enemy-normal', fast: 'enemy-fast', boss: 'enemy-boss',
charger: 'enemy-charger', tank: 'enemy-tank', shooter: 'enemy-shooter',
splitter: 'enemy-fast', // пока используем визуал fast, пока нет отдельной текстуры
};
// Группа статичной геометрии комнаты (пол + стены + двери).
@@ -73,7 +73,7 @@ export class ThreeRenderer implements Renderer {
private chestMesh: THREE.Mesh | null = null;
private pickupMesh: THREE.Mesh | null = null;
private currentPickupWeapon: WeaponId | null = null;
private currentPickupWeapon: string | null = null;
constructor(canvas: HTMLCanvasElement, assetBasePathOrTheme: string | Theme = 'assets', theme: Theme = DEFAULT_THEME) {
const assetBasePath = typeof assetBasePathOrTheme === 'string' ? assetBasePathOrTheme : 'assets';
@@ -347,24 +347,30 @@ export class ThreeRenderer implements Renderer {
private syncPickup(room: Room, visualStep: number): void {
if (room.pickup) {
const wid = room.pickup.weaponId;
if (!this.pickupMesh || this.currentPickupWeapon !== wid) {
// Уникальный ключ для кэша меша: для оружия — его id, для предмета — префикс.
const pk = room.pickup;
const key = pk.kind === 'weapon' && pk.weaponId
? `w:${pk.weaponId}`
: `i:${pk.itemId ?? '?'}`;
if (!this.pickupMesh || this.currentPickupWeapon !== key) {
if (this.pickupMesh) {
this.scene.remove(this.pickupMesh);
(this.pickupMesh.material as THREE.Material).dispose();
}
const tex = pk.kind === 'weapon' && pk.weaponId
? this.assets.weaponIcon(pk.weaponId)
: this.assets.sprite('pickup');
const mat = new THREE.MeshBasicMaterial({
map: this.assets.weaponIcon(wid), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide,
map: tex, transparent: true, alphaTest: 0.3, side: THREE.DoubleSide,
});
this.pickupMesh = new THREE.Mesh(this.vGeo, mat);
this.scene.add(this.pickupMesh);
this.currentPickupWeapon = wid;
this.currentPickupWeapon = key;
}
const p = room.pickup;
const w = p.w * 1.6;
const w = pk.w * 1.6;
const h = w * (48 / 48);
this.pickupMesh.scale.set(w, h, 1);
this.pickupMesh.position.set(p.x, h / 2 + Math.sin(visualStep / 12) * 3, p.y);
this.pickupMesh.position.set(pk.x, h / 2 + Math.sin(visualStep / 12) * 3, pk.y);
} else if (this.pickupMesh) {
this.scene.remove(this.pickupMesh);
(this.pickupMesh.material as THREE.Material).dispose();
+150
View File
@@ -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> = {}): 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);
});
});
+3 -3
View File
@@ -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;
+97
View File
@@ -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> = {}): 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);
});
});
+66
View File
@@ -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);
});
});
+113
View File
@@ -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);
});
});
+271
View File
@@ -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> = {}): 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 };
}
}