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:
@@ -67,10 +67,11 @@
|
||||
|
||||
## Ввод (input/)
|
||||
|
||||
`InputState` — снимок намерений: оси движения, направление прицела, флаги
|
||||
`InputState` — снимок намерений: оси движения, вектор прицела, флаги
|
||||
удержания и однократные «edge»-действия. Делится на:
|
||||
|
||||
- **удерживаемые** (`moveX/Y`, `aimDir`, `attackHeld`) — читаются каждый шаг;
|
||||
- **удерживаемые** (`moveX/Y`, `aimVec`, `attackHeld`) — читаются каждый шаг;
|
||||
`aimVec` — вектор из стрелок, поддерживает 8 направлений (вкл. диагонали);
|
||||
- **однократные** (`toggleWeapon`, `restart`) — срабатывают один раз на нажатие;
|
||||
поэтому они обрабатываются в `consumeActions()` раз в кадр, а не в `step()`.
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/** Поставить позицию мгновенно, сбросив интерполяцию (телепорт). */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user