diff --git a/src/config.ts b/src/config.ts index aad3df4..bbdfff6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -124,6 +124,9 @@ export const ENEMY_STATS = { normal: { size: 32, hp: 3, speed: 1.15, damage: 1 }, fast: { size: 26, hp: 2, speed: 1.9, damage: 1 }, boss: { size: 46, hp: 10, speed: 0.9, damage: 2 }, + 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 }, } as const; export const ENEMY = { @@ -147,3 +150,23 @@ export const SPAWN = { treasureChance: 0.12, // шанс комнаты-сокровищницы bossChance: 0.2, // шанс назначить комнату боссом }; + +// ───────────────────────────────────────────────────────────── +// Сундук (сокровищница) +// ───────────────────────────────────────────────────────────── +export const CHEST = { + size: 40, + hp: 5, +}; + +// ───────────────────────────────────────────────────────────── +// Масштабирование этажей (New Game+) +// ───────────────────────────────────────────────────────────── +export const FLOOR_SCALING = { + roomsPerFloor: 2, // + комнат на этаж + hpMulPerFloor: 0.15, // + множитель HP врагов на этаж + speedMulPerFloor: 0.05, // + множитель скорости на этаж + densityMulPerFloor: 0.1, // + множитель плотности на этаж + bossHpMulPerFloor: 0.2, // + множитель HP босса на этаж + fastChancePerFloor: 0.03, // + доля быстрых врагов на этаж +}; diff --git a/src/core/Game.ts b/src/core/Game.ts index b3b954b..34dcfce 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -1,18 +1,20 @@ import { DIR, DOOR, OX, OY, TILE, COLS, ROWS, T_WALL, - MODE_RANGED, MODE_MELEE, PLAYER, ENEMY, MELEE, + MODE_RANGED, MODE_MELEE, PLAYER, ENEMY, MELEE, PROJECTILE, } from '../config'; import type { Dir } from './types'; import { Rng } from './rng'; import { dist, overlap } from './util'; import { Player } from './entities/Player'; +import { Enemy } from './entities/Enemy'; import { Projectile } from './entities/Projectile'; import { MeleeSwing } from './entities/MeleeSwing'; import { RoomMap } from './world/RoomMap'; import type { Room } from './world/Room'; import { collidesWall } from './systems/collision'; -import { spawnEnemies } from './systems/spawner'; -import { DEFAULT_RULES, type LevelRules } from './rules'; +import { spawnEnemies, spawnChest, pickChestWeapon } from './systems/spawner'; +import { WeaponPickup } from './entities/WeaponPickup'; +import { DEFAULT_RULES, scaleRulesForFloor, type LevelRules } from './rules'; import type { InputState } from '../input/InputState'; import { pressingDir } from '../input/InputState'; @@ -36,6 +38,8 @@ export class Game { meleeSwing: MeleeSwing | null = null; gameOver = false; won = false; + floor = 1; + inventoryOpen = false; /** * @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт». @@ -45,6 +49,7 @@ export class Game { this.rules = rules; this.rng = rng ?? new Rng(rules.seed); this.player = new Player(rules.player); + this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE; this.roomMap = new RoomMap(this.rng, rules); this.enterRoom('up'); } @@ -56,19 +61,33 @@ export class Game { // ── Публичный контракт цикла ────────────────────────────── - /** Однократные действия (смена оружия, рестарт). Вызывать раз в кадр. */ + /** Однократные действия (смена оружия, рестарт, инвентарь). Вызывать раз в кадр. */ consumeActions(input: InputState): void { + if (input.openInventory && !this.gameOver && !this.won) { + this.inventoryOpen = !this.inventoryOpen; + } + if (this.inventoryOpen) return; if (input.toggleWeapon && !this.gameOver && !this.won) { - this.player.mode = this.player.mode === MODE_RANGED ? MODE_MELEE : MODE_RANGED; + const p = this.player; + p.equipped = p.equipped === 0 ? 1 : 0; + p.mode = p.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE; } if (input.restart && (this.gameOver || this.won)) { this.reset(); } } + /** Выбрать слот 0 или 1 (из main.ts при открытом инвентаре). */ + equipSlot(slot: number): void { + if (slot !== 0 && slot !== 1) return; + this.player.equipped = slot; + this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE; + this.inventoryOpen = false; + } + /** Один фиксированный шаг симуляции (= 1/60 c). */ step(input: InputState): void { - if (this.gameOver || this.won) return; + if (this.gameOver || this.won || this.inventoryOpen) return; const room = this.curRoom; const p = this.player; @@ -87,6 +106,8 @@ export class Game { this.handleAttack(input, room, p); this.updateMelee(room); this.updateTears(room); + this.updateChest(room); + this.updatePickup(room, p); const aliveCount = this.updateEnemies(room, p); if (this.gameOver) return; @@ -104,6 +125,7 @@ export class Game { reset(): void { this.gameOver = false; this.won = false; + this.floor = 1; // Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз. this.rng = new Rng(this.rules.seed); this.roomMap = new RoomMap(this.rng, this.rules); @@ -136,6 +158,10 @@ export class Game { if (!room.cleared && room.type !== 'spawn') { room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.rules); + // Сундук в сокровищнице. + if (room.type === 'treasure' && !room.chest) { + room.chest = spawnChest(room, this.rng); + } // Если врагов нет (напр. сокровищница) — зачищать нечего, открываем сразу, // иначе двери никогда не появятся и игрок застрянет. if (room.enemies.length === 0) room.cleared = true; @@ -174,18 +200,45 @@ export class Game { 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; // пробел — по ходу движения + if (input.aimDir) dir = input.aimDir; + else if (input.attackHeld) dir = p.moveDir; if (!dir || p.atkCD > 0) return; p.facing = dir; - p.atkCD = p.mode === MODE_RANGED ? PLAYER.rangedCooldown : PLAYER.meleeCooldown; + const w = p.currentWeapon; + p.atkCD = w.cooldown; const [nx, ny] = DIR[dir]; - if (p.mode === MODE_RANGED) { - room.tears.push(new Projectile(p.x, p.y, nx, ny)); + + if (w.type === 'ranged') { + if (w.projectileType === 'beam') { + // Лазерный луч — стационарная зона поражения. + const range = 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; + room.tears.push(t); + } else if (w.spreadCount && w.spreadCount > 1) { + const spread = 0.15; + const perpX = -ny; + const perpY = nx; + for (let i = 0; i < w.spreadCount; i++) { + const off = (i - (w.spreadCount - 1) / 2) * spread; + const sx = nx + perpX * off; + const sy = ny + perpY * off; + room.tears.push(new Projectile(p.x, p.y, sx, sy, w.projectileType)); + } + } else { + room.tears.push(new Projectile(p.x, p.y, nx, ny, w.projectileType)); + } } else { - this.meleeSwing = new MeleeSwing(p.x, p.y, dir); + this.meleeSwing = new MeleeSwing(p.x, p.y, dir, { + damage: w.damage, + knockback: w.knockback, + life: w.swingLife, + sizeMul: w.swingSizeMul, + }); } } @@ -198,17 +251,45 @@ export class Game { if (!e.alive || e.hitTimer > 0) continue; if (overlap(e.box, this.meleeSwing.box)) { e.hp -= this.meleeSwing.damage; - e.hitTimer = MELEE.life; // защита от повторного удара тем же взмахом + e.hitTimer = MELEE.life; const [dx, dy] = DIR[this.meleeSwing.dir]; e.knx = dx * this.meleeSwing.kb; e.kny = dy * this.meleeSwing.kb; } } + // Удар по сундуку. + if (room.chest?.alive && overlap(room.chest.box, this.meleeSwing.box)) { + room.chest.hp -= this.meleeSwing.damage; + } } private updateTears(room: Room): void { for (const t of room.tears) { if (!t.alive) continue; + + // Лазерный луч: стоит на месте, жжёт врагов каждые 2 тика. + if (t.type === 'beam') { + t.life--; + if (t.life <= 0) continue; + if (t.life % 2 === 0) { + const radius = this.player.currentWeapon.beamRadius ?? 44; + for (const e of room.enemies) { + if (!e.alive) continue; + if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + radius) { + e.hp -= t.damage; + e.hitTimer = ENEMY.hitFlash; + } + } + } + continue; + } + + // Бумеранг: один раз на половине жизни разворачивается. + if (t.type === 'boomerang' && t.life === Math.floor(PROJECTILE.life / 2)) { + t.dx = -t.dx; + t.dy = -t.dy; + } + t.x += t.dx * t.speed; t.y += t.dy * t.speed; t.life--; @@ -216,28 +297,98 @@ export class Game { 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); t.life = 0; continue; } - for (const e of room.enemies) { - if (!e.alive) continue; - if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + t.r) { - e.hp -= t.damage; - e.hitTimer = ENEMY.hitFlash; + + if (t.hostile) { + // Вражеский снаряд: бьёт игрока. + const p = this.player; + if (dist(t.x, t.y, p.x, p.y) < p.w / 2 + t.r && p.invTimer <= 0) { + p.hp -= t.damage; + p.invTimer = PLAYER.invFrames; t.life = 0; - break; + if (p.hp <= 0) { p.hp = 0; this.gameOver = true; return; } + continue; + } + } else { + // Снаряд игрока: бьёт врагов. + for (const e of room.enemies) { + if (!e.alive) continue; + if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + t.r) { + e.hp -= t.damage; + e.hitTimer = ENEMY.hitFlash; + if (t.type === 'fireball') { + const w = this.player.currentWeapon; + e.burnTimer = w.fireDuration ?? 0; + } + if (t.type !== 'laser') { + t.life = 0; + this.explodeBomb(room, t); + break; + } + } + } + // Попадание в сундук. + if (room.chest?.alive && dist(t.x, t.y, room.chest.x, room.chest.y) < room.chest.w / 2 + t.r) { + room.chest.hp -= t.damage; + if (t.type !== 'laser') { + t.life = 0; + this.explodeBomb(room, t); + } } } } room.tears = room.tears.filter((t) => t.alive); } + /** Взрыв бомбы: AoE-урон по врагам. */ + private explodeBomb(room: Room, t: Projectile): void { + if (t.type !== 'bomb') return; + const w = this.player.currentWeapon; + const radius = w.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; + } + } + } + + /** Сундук уничтожен — спавним оружие. */ + 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); + 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); + room.pickup = null; + } + } + private updateEnemies(room: Room, p: Player): number { let aliveCount = 0; + const newEnemies: Enemy[] = []; for (const e of room.enemies) { if (!e.alive) continue; @@ -245,9 +396,17 @@ export class Game { if (e.hitTimer > 0) e.hitTimer--; - // Фаза отбрасывания: летит по инерции, ИИ не работает. Коллизии - // проверяем пораздельно по осям — иначе кнокбэк (до ~4.5 тайла) - // пробивал стену в 1 тайл, и враг застревал снаружи навсегда (софт-лок). + // Горение: урон каждые fireInterval тиков. + if (e.burnTimer > 0) { + e.burnTimer--; + const staff = this.player.weapons.find((w) => w.id === 'staff'); + if (staff && e.burnTimer % (staff.fireInterval ?? 10) === 0) { + e.hp--; + e.hitTimer = ENEMY.hitFlash; + } + } + + // Фаза отбрасывания: летит по инерции, ИИ не работает. if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) { e.x += e.knx * 3; if (collidesWall(e.box, room)) e.x -= e.knx * 3; @@ -260,17 +419,14 @@ export class Game { e.knx = 0; e.kny = 0; - // Преследование игрока. - 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; + 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); } // Контактный урон по игроку. @@ -287,9 +443,150 @@ export class Game { } } + room.enemies.push(...newEnemies); 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; + newEnemies.push(new Enemy(mx, my, 'fast', { hpMul: 1.5, 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 { @@ -315,10 +612,38 @@ export class Game { } } + /** Спуск на следующий этаж: новая карта, усиленные враги, HP/оружие сохраняются. */ + private descend(): void { + this.floor++; + const rules = scaleRulesForFloor(this.rules, this.floor); + + const p = this.player; + const savedHp = p.hp; + const savedMode = p.mode; + + const floorSeed = this.rules.seed !== undefined ? this.rules.seed + this.floor : undefined; + this.rng = floorSeed !== undefined ? new Rng(floorSeed) : new Rng(); + + this.roomMap = new RoomMap(this.rng, rules); + this.cc = 0; + this.cr = 0; + this.meleeSwing = null; + + p.hp = savedHp; + p.atkCD = 0; + p.moveDir = 'up'; + this.enterRoom('up'); + p.mode = savedMode; + } + private checkWin(): void { for (const room of this.roomMap.rooms.values()) { if (room.type === 'boss' && room.cleared) { - this.won = true; + if (this.rules.endless) { + this.descend(); + } else { + this.won = true; + } return; } } diff --git a/src/core/entities/Chest.ts b/src/core/entities/Chest.ts new file mode 100644 index 0000000..5b8b527 --- /dev/null +++ b/src/core/entities/Chest.ts @@ -0,0 +1,24 @@ +import { CHEST } from '../../config'; +import type { Box } from '../types'; + +export class Chest { + x: number; + y: number; + hp: number; + readonly w = CHEST.size; + readonly h = CHEST.size; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + this.hp = CHEST.hp; + } + + get box(): Box { + return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h }; + } + + get alive(): boolean { + return this.hp > 0; + } +} diff --git a/src/core/entities/Enemy.ts b/src/core/entities/Enemy.ts index 7ac5ff8..d5605fa 100644 --- a/src/core/entities/Enemy.ts +++ b/src/core/entities/Enemy.ts @@ -18,10 +18,16 @@ export class Enemy { readonly maxHp: number; readonly speed: number; readonly damage: number; - knx = 0; // отбрасывание по X - kny = 0; // отбрасывание по Y - hitTimer = 0; // мигание при попадании (шаги) - atkTimer = 0; // перезарядка контактного удара (шаги) + knx = 0; // отбрасывание по X + kny = 0; // отбрасывание по Y + hitTimer = 0; // мигание при попадании (шаги) + atkTimer = 0; // перезарядка контактного удара (шаги) + burnTimer = 0; // тиков до конца горения (0 = не горит) + chargeTimer = 0; // перезарядка рывка для charger (шаги) + shootTimer = 0; // перезарядка стрельбы для shooter (шаги) + phase = 1; // фаза босса (мультифазные боссы на этажах 5/10/15) + phaseChanged = false; // флаг для рендера (сброс на след. шаге) + spawnTimer = 0; // перезарядка спавна миньонов для босса /** * mods — множители из правил уровня (см. core/rules.ts). По умолчанию 1, diff --git a/src/core/entities/MeleeSwing.ts b/src/core/entities/MeleeSwing.ts index 777a70e..65de91d 100644 --- a/src/core/entities/MeleeSwing.ts +++ b/src/core/entities/MeleeSwing.ts @@ -1,23 +1,27 @@ import { MELEE, DIR } from '../../config'; import type { Dir, Box } from '../types'; -/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на MELEE.life шагов. */ +/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на life шагов. */ export class MeleeSwing { readonly dir: Dir; life = MELEE.life; - readonly damage = MELEE.damage; - readonly kb = MELEE.knockback; + readonly damage: number; + readonly kb: number; readonly box: Box; - constructor(x: number, y: number, dir: Dir) { + constructor(x: number, y: number, dir: Dir, overrides?: { damage?: number; knockback?: number; life?: number; sizeMul?: number }) { this.dir = dir; - const { reach: d, size: s } = MELEE; + this.damage = overrides?.damage ?? MELEE.damage; + this.kb = overrides?.knockback ?? MELEE.knockback; + if (overrides?.life !== undefined) this.life = overrides.life; + const { reach: d } = MELEE; + const size = MELEE.size * (overrides?.sizeMul ?? 1); const [dx, dy] = DIR[dir]; this.box = { - x: x + (dx > 0 ? d : dx < 0 ? -d - s : -s / 2), - y: y + (dy > 0 ? d : dy < 0 ? -d - s : -s / 2), - w: s, - h: s, + x: x + (dx > 0 ? d : dx < 0 ? -d - size : -size / 2), + y: y + (dy > 0 ? d : dy < 0 ? -d - size : -size / 2), + w: size, + h: size, }; } diff --git a/src/core/entities/Player.ts b/src/core/entities/Player.ts index c8a2d12..99b46f7 100644 --- a/src/core/entities/Player.ts +++ b/src/core/entities/Player.ts @@ -1,4 +1,5 @@ import { PLAYER, MODE_RANGED } from '../../config'; +import { WEAPONS, type WeaponId, type WeaponDef } from '../weapons'; import type { CombatMode, Box, Dir } from '../types'; /** @@ -23,6 +24,11 @@ export class Player { invTimer = 0; // неуязвимость (шаги) transCD = 0; // блок перехода между комнатами (шаги) + /** Ровно 2 слота под оружие. */ + weapons: [WeaponDef, WeaponDef] = [WEAPONS.tears, WEAPONS.melee]; + /** 0 или 1 — какой слот сейчас экипирован. */ + equipped: 0 | 1 = 0; + /** Переопределения из правил уровня; по умолчанию — баланс из config. */ constructor(rules: { maxHp?: number; speed?: number } = {}) { this.maxHp = rules.maxHp ?? PLAYER.maxHp; @@ -35,6 +41,16 @@ export class Player { return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h }; } + get currentWeapon(): WeaponDef { + return this.weapons[this.equipped]; + } + + /** Подобрать оружие — заменяет текущий экипированный слот. */ + addWeapon(id: WeaponId): void { + this.weapons[this.equipped] = WEAPONS[id]; + this.mode = WEAPONS[id].type === 'ranged' ? MODE_RANGED : 1; + } + /** Поставить позицию мгновенно, сбросив интерполяцию (телепорт). */ place(x: number, y: number): void { this.x = this.prevX = x; diff --git a/src/core/entities/Projectile.ts b/src/core/entities/Projectile.ts index 053417b..296f437 100644 --- a/src/core/entities/Projectile.ts +++ b/src/core/entities/Projectile.ts @@ -1,6 +1,7 @@ import { PROJECTILE } from '../../config'; +import type { ProjectileType } from '../types'; -/** Снаряд игрока («слеза»). Летит по прямой, пока не врежется или не истечёт life. */ +/** Снаряд. Летит по прямой, пока не врежется или не истечёт life. */ export class Projectile { x: number; y: number; @@ -8,16 +9,19 @@ export class Projectile { prevY: number; dx: number; dy: number; + readonly type: ProjectileType; readonly r = PROJECTILE.radius; - readonly speed = PROJECTILE.speed; - readonly damage = PROJECTILE.damage; + speed = PROJECTILE.speed; + damage = PROJECTILE.damage; life = PROJECTILE.life; + hostile = false; // true = вражеский снаряд, бьёт игрока - constructor(x: number, y: number, dx: number, dy: number) { + constructor(x: number, y: number, dx: number, dy: number, type: ProjectileType = 'tear') { this.x = this.prevX = x; this.y = this.prevY = y; this.dx = dx; this.dy = dy; + this.type = type; } get alive(): boolean { diff --git a/src/core/entities/WeaponPickup.ts b/src/core/entities/WeaponPickup.ts new file mode 100644 index 0000000..d910b7e --- /dev/null +++ b/src/core/entities/WeaponPickup.ts @@ -0,0 +1,20 @@ +import type { Box } from '../types'; +import type { WeaponId } from '../weapons'; + +export class WeaponPickup { + x: number; + y: number; + readonly weaponId: WeaponId; + readonly w = 30; + readonly h = 30; + + constructor(x: number, y: number, weaponId: WeaponId) { + this.x = x; + this.y = y; + this.weaponId = weaponId; + } + + get box(): Box { + return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h }; + } +} diff --git a/src/core/rules.ts b/src/core/rules.ts index 6ae1b51..b46ae3f 100644 --- a/src/core/rules.ts +++ b/src/core/rules.ts @@ -10,7 +10,7 @@ * Геометрия (размер тайла/комнаты, геометрия дверей) остаётся в config.ts: это * не «правила уровня», а константы движка. */ -import { PLAYER, ENEMY, MIN_ROOMS, EXTRA_ROOMS, MAP_RADIUS } from '../config'; +import { PLAYER, ENEMY, MIN_ROOMS, EXTRA_ROOMS, MAP_RADIUS, FLOOR_SCALING } from '../config'; export interface LevelRules { /** Машинный id (для сохранений/выбора). */ @@ -21,6 +21,8 @@ export interface LevelRules { description: string; /** Фиксированный seed генерации. undefined → случайный каждый забег. */ seed?: number; + /** Бесконечный спуск: после босса — новый этаж с усилением, а не победа. */ + endless?: boolean; /** Параметры генерации карты. */ map: { @@ -91,4 +93,38 @@ export const PRESETS: LevelRules[] = [ player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed }, enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 }, }, + { + id: 'endless', + name: 'Бесконечный спуск', + description: 'После босса — спуск на новый этаж. Враги сильнее, комнат больше. HP и оружие сохраняются.', + endless: true, + map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS }, + player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed }, + enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 }, + }, ]; + +/** + * Возвращает правила для указанного этажа на основе базовых правил. + * С каждым этажом враги сильнее и комнат больше. + */ +export function scaleRulesForFloor(base: LevelRules, floor: number): LevelRules { + const f = floor - 1; + const cap = (v: number): number => Math.round(v * 100) / 100; + return { + ...base, + map: { + ...base.map, + minRooms: base.map.minRooms + f * FLOOR_SCALING.roomsPerFloor, + mapRadius: Math.min(base.map.mapRadius + Math.floor(f / 3), 6), + }, + enemies: { + densityMul: cap(base.enemies.densityMul + f * FLOOR_SCALING.densityMulPerFloor), + fastChance: Math.min(base.enemies.fastChance + f * FLOOR_SCALING.fastChancePerFloor, 0.8), + hpMul: cap(base.enemies.hpMul + f * FLOOR_SCALING.hpMulPerFloor), + speedMul: cap(base.enemies.speedMul + f * FLOOR_SCALING.speedMulPerFloor), + bossHpMul: cap(base.enemies.bossHpMul + f * FLOOR_SCALING.bossHpMulPerFloor), + }, + endless: true, + }; +} diff --git a/src/core/systems/spawner.ts b/src/core/systems/spawner.ts index 3e095d7..402e20c 100644 --- a/src/core/systems/spawner.ts +++ b/src/core/systems/spawner.ts @@ -1,9 +1,11 @@ import { OX, OY, TILE, COLS, ROWS, DOOR, SPAWN } from '../../config'; import { Enemy } from '../entities/Enemy'; +import { Chest } from '../entities/Chest'; import { dist } from '../util'; import type { Room } from '../world/Room'; import type { Dir, EnemyType } from '../types'; import type { Rng } from '../rng'; +import type { WeaponId } from '../weapons'; import { DEFAULT_RULES, type LevelRules } from '../rules'; function isSpawnSpotClear( @@ -29,6 +31,17 @@ function isSpawnSpotClear( * берутся из правил уровня (rules). Возвращает массив — вызывающий код кладёт * его в room.enemies. */ +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 + 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'; +} + export function spawnEnemies( room: Room, entryDir: Dir, @@ -50,8 +63,7 @@ export function spawnEnemies( const doorY = OY + door.cy * TILE + TILE / 2; for (let i = 0; i < count; i++) { - const type: EnemyType = - room.type === 'boss' ? 'boss' : rng.chance(er.fastChance) ? 'fast' : 'normal'; + const type: EnemyType = pickEnemyType(room, rng, er.fastChance); const mods = { hpMul: er.hpMul * (type === 'boss' ? er.bossHpMul : 1), speedMul: er.speedMul, @@ -72,3 +84,15 @@ export function spawnEnemies( return enemies; } + +const TREASURE_WEAPONS: WeaponId[] = ['shotgun', 'axe', 'staff', 'whip', 'bomb', 'boomerang', 'laser']; + +export function spawnChest(room: Room, rng: Rng): Chest { + const cx = OX + (COLS / 2) * TILE; + const cy = OY + (ROWS / 2) * TILE; + return new Chest(cx, cy); +} + +export function pickChestWeapon(rng: Rng): WeaponId { + return rng.pick(TREASURE_WEAPONS); +} diff --git a/src/core/types.ts b/src/core/types.ts index 4f29d43..e96658c 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -3,7 +3,8 @@ export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss'; export type Dir = 'up' | 'down' | 'left' | 'right'; export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE -export type EnemyType = 'normal' | 'fast' | 'boss'; +export type EnemyType = 'normal' | 'fast' | 'boss' | 'charger' | 'tank' | 'shooter'; +export type ProjectileType = 'tear' | 'fireball' | 'bomb' | 'boomerang' | 'laser' | 'beam'; /** Прямоугольник (axis-aligned bounding box) для коллизий. */ export interface Box { diff --git a/src/core/weapons.ts b/src/core/weapons.ts new file mode 100644 index 0000000..b2f113b --- /dev/null +++ b/src/core/weapons.ts @@ -0,0 +1,35 @@ +export type WeaponId = 'tears' | 'melee' | 'shotgun' | 'axe' | 'staff' | 'whip' | 'bomb' | 'boomerang' | 'laser'; + +export type ProjectileType = 'tear' | 'fireball' | 'bomb' | 'boomerang' | 'laser' | 'beam'; + +export interface WeaponDef { + id: WeaponId; + name: string; + type: 'ranged' | 'melee'; + damage: number; + cooldown: number; + projectileType?: ProjectileType; + spreadCount?: number; + swingSizeMul?: number; + swingLife?: number; + knockback?: number; + fireDmg?: number; + fireInterval?: number; + fireDuration?: number; + explosionRadius?: number; // для бомбы + beamLife?: number; // длительность лазерного луча + beamRadius?: number; // радиус поражения луча + beamTickDmg?: number; // урон за тик луча +} + +export const WEAPONS: Record = { + tears: { id: 'tears', name: 'Слёзы', type: 'ranged', damage: 1, cooldown: 10, projectileType: 'tear' }, + melee: { id: 'melee', name: 'Кулак', type: 'melee', damage: 2, cooldown: 22 }, + shotgun: { id: 'shotgun', name: 'Дробовик', type: 'ranged', damage: 1, cooldown: 18, projectileType: 'tear', spreadCount: 3 }, + 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 }, +}; diff --git a/src/core/world/Room.ts b/src/core/world/Room.ts index e74c174..d0d652f 100644 --- a/src/core/world/Room.ts +++ b/src/core/world/Room.ts @@ -2,6 +2,8 @@ import type { RoomType, Doors } from '../types'; 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'; /** * Комната дандженa. Хранит свой тип, набор дверей, состояние «зачищена/ @@ -17,6 +19,8 @@ export class Room { cleared = false; enemies: Enemy[] = []; tears: Projectile[] = []; + chest: Chest | null = null; + pickup: WeaponPickup | null = null; tiles: number[][]; constructor(c: number, r: number, type: RoomType) { diff --git a/src/input/InputState.ts b/src/input/InputState.ts index 8c8915a..b06533f 100644 --- a/src/input/InputState.ts +++ b/src/input/InputState.ts @@ -17,6 +17,7 @@ export interface InputState { attackHeld: boolean; // атака «по ходу движения» (пробел) toggleWeapon: boolean; // сменить оружие (однократно) restart: boolean; // рестарт на экране конца игры (однократно) + openInventory: boolean; // открыть инвентарь (однократно, E) } /** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */ @@ -33,6 +34,7 @@ export function emptyInput(): InputState { attackHeld: false, toggleWeapon: false, restart: false, + openInventory: false, }; } diff --git a/src/input/KeyboardController.ts b/src/input/KeyboardController.ts index 1fd78c5..157b2a0 100644 --- a/src/input/KeyboardController.ts +++ b/src/input/KeyboardController.ts @@ -18,6 +18,7 @@ export class KeyboardController implements InputSource { private held = new Set(); private toggleWeaponEdge = false; private restartEdge = false; + private inventoryEdge = false; private attached = false; private onKeyDown = (e: KeyboardEvent): void => { @@ -26,6 +27,7 @@ export class KeyboardController implements InputSource { if (!this.held.has(c)) { if (c === 'Tab' || c === 'KeyQ') this.toggleWeaponEdge = true; if (c === 'KeyR') this.restartEdge = true; + if (c === 'KeyE') this.inventoryEdge = true; } this.held.add(c); if (PREVENT.has(c)) e.preventDefault(); @@ -49,6 +51,7 @@ export class KeyboardController implements InputSource { this.held.clear(); this.toggleWeaponEdge = false; this.restartEdge = false; + this.inventoryEdge = false; } /** Подписаться на события окна. Вызывается один раз при старте. */ @@ -84,15 +87,17 @@ export class KeyboardController implements InputSource { attackHeld: down('Space'), toggleWeapon: this.toggleWeaponEdge, restart: this.restartEdge, + openInventory: this.inventoryEdge, }; this.toggleWeaponEdge = false; this.restartEdge = false; + this.inventoryEdge = false; return snapshot; } } /** Физические клавиши (e.code), у которых гасим поведение браузера (скролл/таб). */ const PREVENT = new Set([ - 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab', + 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab', 'KeyE', ]); diff --git a/src/main.ts b/src/main.ts index 2e08329..470f8b0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -58,9 +58,24 @@ function boot(): void { // Esc во время игры — вернуться к выбору уровня (по физической клавише). window.addEventListener('keydown', (e) => { if (e.code === 'Escape' && loop) { + const g = (window as Window & { game?: Game }).game; + if (g?.inventoryOpen) { + g.inventoryOpen = false; + e.preventDefault(); + return; + } e.preventDefault(); toMenu(); } + // Цифры 1-9 для экипировки оружия в инвентаре. + const digit = parseInt(e.code.replace('Digit', ''), 10); + if (digit >= 1 && digit <= 9 && loop) { + const g = (window as Window & { game?: Game }).game; + if (g?.inventoryOpen) { + g.equipSlot(digit - 1); + e.preventDefault(); + } + } }); } diff --git a/src/render/HudOverlay.ts b/src/render/HudOverlay.ts index 5779708..3770778 100644 --- a/src/render/HudOverlay.ts +++ b/src/render/HudOverlay.ts @@ -1,4 +1,4 @@ -import { CW, CH, OY, RH, MODE_RANGED } from '../config'; +import { CW, CH, OY, RH } from '../config'; import type { Game } from '../core/Game'; import type { Renderer } from './Renderer'; @@ -36,6 +36,11 @@ export class HudOverlay implements Renderer { const ctx = this.ctx; ctx.clearRect(0, 0, CW, CH); + if (game.inventoryOpen) { + this.drawInventory(game); + return; + } + this.drawHud(game); this.drawMinimap(game); @@ -55,22 +60,21 @@ export class HudOverlay implements Renderer { // Здоровье: сердечки (2 HP = сердце), с фолбэком на полосу. const healthBottom = this.drawHealth(p.hp, p.maxHp); - // Название текущего уровня (правил). + // Название пресета и номер этажа (для бесконечного спуска). ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace'; - ctx.fillText(`Уровень: ${game.rules.name}`, 20, healthBottom + 14); + const floorLabel = game.rules.endless ? ` | Этаж ${game.floor}` : ''; + ctx.fillText(`${game.rules.name}${floorLabel}`, 20, healthBottom + 14); - // Индикатор режима боя. + // Индикатор оружия. const my = CH - 46; - const ranged = p.mode === MODE_RANGED; - const mText = ranged ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ'; - const mCol = ranged ? '#4488cc' : '#cc6644'; + const w = p.currentWeapon; + const wCol = w.type === 'ranged' ? '#4488cc' : '#cc6644'; ctx.textAlign = 'center'; ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34); - ctx.strokeStyle = mCol; ctx.lineWidth = 2; ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34); - ctx.fillStyle = mCol; ctx.font = 'bold 17px monospace'; ctx.fillText(`[ ${mText} ]`, CW / 2, my + 8); + ctx.strokeStyle = wCol; ctx.lineWidth = 2; ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34); + ctx.fillStyle = wCol; ctx.font = 'bold 17px monospace'; ctx.fillText(`[ ${w.name} ]`, CW / 2, my + 8); ctx.fillStyle = '#555'; ctx.font = '11px monospace'; ctx.fillText('[Tab] сменить оружие', CW / 2, my - 26); - // Иконка оружия слева в рамке (если ассет есть). - const icon = this.img(ranged ? 'icon-ranged' : 'icon-melee'); + const icon = this.img(w.type === 'ranged' ? 'icon-ranged' : 'icon-melee'); if (icon) ctx.drawImage(icon, CW / 2 - 90, my - 14, 26, 26); // Счётчик врагов / подсказка зачистки. @@ -167,4 +171,48 @@ export class HudOverlay implements Renderer { ctx.fillStyle = '#666'; ctx.font = '14px monospace'; ctx.fillText('[Esc] в меню', CW / 2, CH / 2 + 68); } + + private drawInventory(game: import('../core/Game').Game): void { + const ctx = this.ctx; + ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(0, 0, CW, CH); + + ctx.fillStyle = '#ddd'; ctx.font = 'bold 28px monospace'; ctx.textAlign = 'center'; + ctx.fillText('ИНВЕНТАРЬ (2 слота)', CW / 2, 50); + + const p = game.player; + const iw = 340, ih = 56, gap = 16; + const total = iw * 2 + gap; + const ox = CW / 2 - total / 2; + const oy = 100; + + for (let i = 0; i < 2; i++) { + const w = p.weapons[i]; + const x = ox + i * (iw + gap); + const y = oy; + const selected = i === p.equipped; + + ctx.fillStyle = selected ? '#1a2a1a' : '#111'; + ctx.fillRect(x, y, iw, ih); + ctx.strokeStyle = selected ? '#4c4' : '#333'; + ctx.lineWidth = selected ? 2 : 1; + ctx.strokeRect(x, y, iw, ih); + + ctx.fillStyle = '#888'; ctx.font = '14px monospace'; ctx.textAlign = 'left'; + ctx.fillText(`[${i + 1}]`, x + 12, y + 34); + + ctx.fillStyle = selected ? '#4c4' : '#ccc'; ctx.font = 'bold 16px monospace'; + ctx.fillText(w.name, x + 48, y + 34); + + ctx.fillStyle = '#666'; ctx.font = '12px monospace'; + ctx.textAlign = 'right'; + const t = w.type === 'ranged' ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ'; + ctx.fillText(`${t} DMG:${w.damage} CD:${w.cooldown}`, x + iw - 12, y + 34); + + const icon = this.img(w.type === 'ranged' ? 'icon-ranged' : 'icon-melee'); + if (icon) ctx.drawImage(icon, x + iw - 44, y + 16, 24, 24); + } + + ctx.fillStyle = '#555'; ctx.font = '13px monospace'; ctx.textAlign = 'center'; + ctx.fillText('[E] закрыть | [1] [2] слот | [Tab] переключить', CW / 2, CH - 30); + } } diff --git a/src/render/ThreeRenderer.ts b/src/render/ThreeRenderer.ts index 1b39768..42e57cb 100644 --- a/src/render/ThreeRenderer.ts +++ b/src/render/ThreeRenderer.ts @@ -8,6 +8,7 @@ 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'; @@ -53,6 +54,7 @@ export class ThreeRenderer implements Renderer { private readonly playerMat: Record<'ranged' | 'melee', THREE.MeshBasicMaterial>; private readonly enemyMatKey: Record = { normal: 'enemy-normal', fast: 'enemy-fast', boss: 'enemy-boss', + charger: 'enemy-charger', tank: 'enemy-tank', shooter: 'enemy-shooter', }; // Группа статичной геометрии комнаты (пол + стены + двери). @@ -69,6 +71,10 @@ export class ThreeRenderer implements Renderer { private readonly effects: Effect[] = []; private lastAtkCD = 0; + private chestMesh: THREE.Mesh | null = null; + private pickupMesh: THREE.Mesh | null = null; + private currentPickupWeapon: WeaponId | null = null; + constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) { this.theme = theme; this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); @@ -130,6 +136,8 @@ export class ThreeRenderer implements Renderer { this.syncEnemies(room, alpha); this.syncTears(room, alpha); this.syncSwing(game); + this.syncChest(room); + this.syncPickup(room); this.updateEffects(); this.renderer.render(this.scene, this.camera); @@ -239,18 +247,27 @@ export class ThreeRenderer implements Renderer { const x = lerp(e.prevX, e.x, alpha); const z = lerp(e.prevY, e.y, alpha); - const pop = 1 + 0.18 * (e.hitTimer / Math.max(1, MELEE.life)); // «дёргается» при попадании + const pop = 1 + 0.18 * (e.hitTimer / Math.max(1, MELEE.life)); const w = e.w * SPRITE_SCALE * pop; const h = w * SPRITE_ASPECT; v.sprite.scale.set(w, h, 1); v.sprite.position.set(x, h / 2, z); this.placeShadow(v.shadow, x, z, e.w); - // Искра в момент попадания (hitTimer вырос). if (e.hitTimer > v.lastHit) { - this.spawnEffect(this.assets.spark(), this.theme.flash, x, e.w * 0.6, z, e.w * 0.9, 8, { vy: 0.6, grow: 1.06 }); + // Увеличенный эффект для смены фазы босса (15+ тиков) + const size = e.hitTimer >= 12 ? e.w * 1.5 : e.w * 0.9; + const life = e.hitTimer >= 12 ? 16 : 8; + this.spawnEffect(this.assets.puff(), 0xff6600, x, e.w * 0.6, z, size, life, { vy: 0.8, grow: 1.06 }); } v.lastHit = e.hitTimer; + + // Цвет подкраски по фазе босса. + let phaseTint = 0xffffff; + if (e.burnTimer > 0) phaseTint = 0xff6644; + else if (e.type === 'boss' && e.phase === 2) phaseTint = 0xff8844; + else if (e.type === 'boss' && e.phase >= 3) phaseTint = 0xff3300; + (v.sprite.material as THREE.MeshBasicMaterial).color.setHex(phaseTint); } // Уборка: исчезнувшие враги. Если враг мёртв — «пуф» на месте гибели. @@ -272,15 +289,21 @@ export class ThreeRenderer implements Renderer { live.add(t); let mesh = this.tearMeshes.get(t); if (!mesh) { + const tex = + t.type === 'fireball' ? this.assets.fireball() : + t.type === 'beam' ? this.assets.sprite('beam') : + this.assets.tear(); mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({ - map: this.assets.tear(), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, + map: tex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, })); - const s = PROJECTILE.radius * 4; + const s = t.type === 'fireball' ? PROJECTILE.radius * 5 : + t.type === 'beam' ? 50 : + PROJECTILE.radius * 4; mesh.scale.set(s, s, 1); this.scene.add(mesh); this.tearMeshes.set(t, mesh); } - mesh.position.set(lerp(t.prevX, t.x, alpha), TEAR_Y, lerp(t.prevY, t.y, alpha)); + mesh.position.set(lerp(t.prevX, t.x, alpha), t.type === 'beam' ? 10 : TEAR_Y, lerp(t.prevY, t.y, alpha)); } for (const [t, mesh] of this.tearMeshes) { if (live.has(t)) continue; @@ -299,6 +322,55 @@ export class ThreeRenderer implements Renderer { (this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / MELEE.life); } + private syncChest(room: Room): void { + if (room.chest?.alive) { + if (!this.chestMesh) { + const mat = new THREE.MeshBasicMaterial({ + map: this.assets.sprite('chest'), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide, + }); + this.chestMesh = new THREE.Mesh(this.vGeo, mat); + this.scene.add(this.chestMesh); + } + const c = room.chest; + const w = c.w * 1.2; + const h = w * (64 / 48); + this.chestMesh.scale.set(w, h, 1); + this.chestMesh.position.set(c.x, h / 2, c.y); + } else if (this.chestMesh) { + this.scene.remove(this.chestMesh); + (this.chestMesh.material as THREE.Material).dispose(); + this.chestMesh = null; + } + } + + private syncPickup(room: Room): void { + if (room.pickup) { + const wid = room.pickup.weaponId; + if (!this.pickupMesh || this.currentPickupWeapon !== wid) { + if (this.pickupMesh) { + this.scene.remove(this.pickupMesh); + (this.pickupMesh.material as THREE.Material).dispose(); + } + const mat = new THREE.MeshBasicMaterial({ + map: this.assets.weaponIcon(wid), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide, + }); + this.pickupMesh = new THREE.Mesh(this.vGeo, mat); + this.scene.add(this.pickupMesh); + this.currentPickupWeapon = wid; + } + const p = room.pickup; + const w = p.w * 1.6; + const h = w * (48 / 48); + this.pickupMesh.scale.set(w, h, 1); + this.pickupMesh.position.set(p.x, h / 2 + Math.sin(Date.now() / 200) * 3, p.y); + } else if (this.pickupMesh) { + this.scene.remove(this.pickupMesh); + (this.pickupMesh.material as THREE.Material).dispose(); + this.pickupMesh = null; + this.currentPickupWeapon = null; + } + } + // ── Эффекты (частицы-биллборды) ─────────────────────────── private spawnEffect( @@ -362,6 +434,8 @@ export class ThreeRenderer implements Renderer { for (const m of this.tearMeshes.values()) (m.material as THREE.Material).dispose(); for (const fx of this.effects) (fx.mesh.material as THREE.Material).dispose(); (this.swingMesh.material as THREE.Material).dispose(); + if (this.chestMesh) (this.chestMesh.material as THREE.Material).dispose(); + if (this.pickupMesh) (this.pickupMesh.material as THREE.Material).dispose(); this.floorMat.dispose(); this.wallMat.dispose(); this.shadowMat.dispose(); diff --git a/src/render/assets.ts b/src/render/assets.ts index 2445c19..bef841a 100644 --- a/src/render/assets.ts +++ b/src/render/assets.ts @@ -1,4 +1,5 @@ import * as THREE from 'three'; +import type { WeaponId } from '../core/weapons'; /** * assets.ts — поставщик текстур. Каждая текстура грузится из @@ -14,7 +15,9 @@ import * as THREE from 'three'; export type SpriteKey = | 'player-ranged' | 'player-melee' - | 'enemy-normal' | 'enemy-fast' | 'enemy-boss'; + | 'enemy-normal' | 'enemy-fast' | 'enemy-boss' + | 'enemy-charger' | 'enemy-tank' | 'enemy-shooter' + | 'chest' | 'pickup' | 'fireball' | 'beam'; function canvas(w: number, h: number): { cv: HTMLCanvasElement; ctx: CanvasRenderingContext2D } { const cv = document.createElement('canvas'); @@ -166,6 +169,183 @@ function drawDoor(open: boolean): HTMLCanvasElement { return cv; } +/** Сундук: тёмный ящик с золотым ободком. */ +function drawChest(): HTMLCanvasElement { + const { cv, ctx } = canvas(48, 48); + ctx.fillStyle = '#5a3a1a'; + roundRect(ctx, 4, 4, 40, 40, 4); + ctx.fill(); + ctx.strokeStyle = '#8a6a2a'; + ctx.lineWidth = 3; + roundRect(ctx, 4, 4, 40, 40, 4); + ctx.stroke(); + ctx.fillStyle = '#c9a84a'; + ctx.fillRect(12, 18, 24, 10); + ctx.fillStyle = '#8a6a2a'; + ctx.fillRect(22, 14, 4, 18); + ctx.fillStyle = '#3a220a'; + ctx.beginPath(); ctx.arc(24, 24, 4, 0, Math.PI * 2); ctx.fill(); + return cv; +} + +/** Пикап оружия: парящий ромб со свечением. */ +function drawPickup(): HTMLCanvasElement { + const { cv, ctx } = canvas(32, 32); + const g = ctx.createRadialGradient(16, 16, 1, 16, 16, 15); + g.addColorStop(0, '#ffdd88'); + g.addColorStop(0.4, '#cc8822'); + g.addColorStop(1, 'rgba(200,100,0,0)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, 32, 32); + ctx.fillStyle = '#ffcc44'; + ctx.beginPath(); + ctx.moveTo(16, 4); ctx.lineTo(28, 16); ctx.lineTo(16, 28); ctx.lineTo(4, 16); ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = '#aa6600'; + ctx.lineWidth = 1.5; + ctx.stroke(); + return cv; +} + +/** Зарядчик: красный, агрессивный вид, щель глаза. */ +function drawCharger(): HTMLCanvasElement { + return drawCharacter({ head: '#cc4422', body: '#882211', outline: '#330a04', eye: '#ffaa00', small: false }); +} + +/** Танк: большой, тёмный, тяжёлый. */ +function drawTank(): HTMLCanvasElement { + return drawCharacter({ head: '#554433', body: '#443322', outline: '#1a110a', eye: '#ff6622', horns: true }); +} + +/** Стрелок: синеватый, с «шапкой». */ +function drawShooter(): HTMLCanvasElement { + return drawCharacter({ head: '#4488aa', body: '#336688', outline: '#122436', eye: '#aaddff', small: false }); +} + +/** Лазерный луч: яркая бело-голубая полоса. */ +function drawBeam(): HTMLCanvasElement { + const { cv, ctx } = canvas(64, 64); + const g = ctx.createRadialGradient(32, 32, 2, 32, 32, 30); + g.addColorStop(0, '#ffffff'); + g.addColorStop(0.2, '#88ddff'); + g.addColorStop(0.5, '#4488ff'); + g.addColorStop(1, 'rgba(0,50,200,0)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, 64, 64); + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.fillRect(20, 28, 24, 8); + return cv; +} + +/** Огненный шар: красно-оранжевый с бликом. */ +function drawFireball(): HTMLCanvasElement { + const { cv, ctx } = canvas(32, 32); + const g = ctx.createRadialGradient(16, 16, 1, 16, 16, 14); + g.addColorStop(0, '#ffee88'); + g.addColorStop(0.3, '#ff6622'); + g.addColorStop(0.7, '#cc2200'); + g.addColorStop(1, 'rgba(100,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); ctx.arc(16, 16, 14, 0, Math.PI * 2); ctx.fill(); + return cv; +} + +/** Иконка оружия: слеза (голубая капля). */ +function drawWeaponTears(): HTMLCanvasElement { + const { cv, ctx } = canvas(48, 48); + const g = ctx.createRadialGradient(24, 24, 2, 24, 24, 20); + g.addColorStop(0, '#dff0ff'); g.addColorStop(0.5, '#6699cc'); g.addColorStop(1, 'rgba(40,80,140,0)'); + ctx.fillStyle = g; ctx.beginPath(); ctx.arc(24, 24, 20, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#aaccee'; ctx.beginPath(); ctx.arc(20, 18, 6, 0, Math.PI * 2); ctx.fill(); + return cv; +} + +/** Иконка оружия: кулак. */ +function drawWeaponMelee(): HTMLCanvasElement { + const { cv, ctx } = canvas(48, 48); + ctx.fillStyle = '#8a6a3a'; + roundRect(ctx, 10, 14, 28, 24, 6); ctx.fill(); + ctx.strokeStyle = '#4a2a0a'; ctx.lineWidth = 2; roundRect(ctx, 10, 14, 28, 24, 6); ctx.stroke(); + ctx.fillStyle = '#6a4a1a'; ctx.fillRect(14, 20, 8, 8); ctx.fillRect(26, 20, 8, 8); + ctx.fillRect(18, 30, 12, 6); + ctx.fillStyle = '#5a3a0a'; ctx.fillRect(20, 6, 8, 12); + return cv; +} + +/** Иконка оружия: дробовик — три точки. */ +function drawWeaponShotgun(): HTMLCanvasElement { + const { cv, ctx } = canvas(48, 48); + for (const [x, y] of [[24, 16], [16, 30], [32, 30]]) { + const g = ctx.createRadialGradient(x, y, 1, x, y, 10); + g.addColorStop(0, '#ffcc44'); g.addColorStop(0.5, '#cc6622'); g.addColorStop(1, 'rgba(150,60,0,0)'); + ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, 10, 0, Math.PI * 2); ctx.fill(); + } + return cv; +} + +/** Иконка оружия: топор. */ +function drawWeaponAxe(): HTMLCanvasElement { + const { cv, ctx } = canvas(48, 48); + ctx.fillStyle = '#777'; + ctx.beginPath(); ctx.moveTo(8, 16); ctx.lineTo(38, 12); ctx.lineTo(40, 22); ctx.lineTo(30, 22); ctx.lineTo(30, 38); ctx.lineTo(16, 38); ctx.lineTo(16, 22); ctx.lineTo(6, 22); ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#333'; ctx.lineWidth = 2; ctx.stroke(); + ctx.fillStyle = '#5a3a0a'; ctx.fillRect(22, 34, 4, 12); + return cv; +} + +/** Иконка оружия: посох. */ +function drawWeaponStaff(): HTMLCanvasElement { + const { cv, ctx } = canvas(48, 48); + ctx.fillStyle = '#6a4a2a'; ctx.fillRect(22, 6, 4, 36); + ctx.fillStyle = '#ff4422'; + ctx.beginPath(); ctx.arc(24, 10, 10, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#ffcc44'; + ctx.beginPath(); ctx.arc(24, 10, 5, 0, Math.PI * 2); ctx.fill(); + return cv; +} + +/** Иконка оружия: хлыст. */ +function drawWeaponWhip(): HTMLCanvasElement { + const { cv, ctx } = canvas(48, 48); + ctx.strokeStyle = '#8a6a3a'; ctx.lineWidth = 4; ctx.lineCap = 'round'; + ctx.beginPath(); ctx.moveTo(10, 38); ctx.quadraticCurveTo(18, 12, 38, 14); ctx.stroke(); + ctx.strokeStyle = '#5a3a0a'; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(10, 38); ctx.quadraticCurveTo(18, 12, 38, 14); ctx.stroke(); + ctx.fillStyle = '#4a2a0a'; ctx.fillRect(6, 34, 8, 8); + return cv; +} + +/** Иконка оружия: бомба. */ +function drawWeaponBomb(): HTMLCanvasElement { + const { cv, ctx } = canvas(48, 48); + ctx.fillStyle = '#333'; ctx.beginPath(); ctx.arc(24, 24, 16, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#555'; ctx.beginPath(); ctx.arc(24, 24, 10, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#cc4422'; ctx.fillRect(22, 4, 4, 8); + ctx.fillStyle = '#ff8844'; ctx.beginPath(); ctx.arc(24, 4, 4, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#222'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(24, 24, 16, 0, Math.PI * 2); ctx.stroke(); + return cv; +} + +/** Иконка оружия: бумеранг. */ +function drawWeaponBoomerang(): HTMLCanvasElement { + const { cv, ctx } = canvas(48, 48); + ctx.fillStyle = '#8a6a3a'; + ctx.beginPath(); ctx.moveTo(8, 36); ctx.lineTo(22, 16); ctx.lineTo(40, 6); ctx.lineTo(38, 18); ctx.lineTo(22, 28); ctx.lineTo(18, 36); ctx.closePath(); ctx.fill(); + ctx.strokeStyle = '#4a2a0a'; ctx.lineWidth = 2; ctx.stroke(); + ctx.fillStyle = '#a08850'; ctx.fillRect(8, 30, 12, 8); + return cv; +} + +/** Иконка оружия: лазер. */ +function drawWeaponLaser(): HTMLCanvasElement { + const { cv, ctx } = canvas(48, 48); + const g = ctx.createLinearGradient(8, 24, 40, 24); + g.addColorStop(0, 'rgba(100,180,255,0.2)'); g.addColorStop(0.3, '#88ddff'); g.addColorStop(0.5, '#ffffff'); g.addColorStop(0.7, '#88ddff'); g.addColorStop(1, 'rgba(100,180,255,0.2)'); + ctx.fillStyle = g; ctx.fillRect(8, 18, 32, 12); + ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.fillRect(12, 22, 24, 4); + return cv; +} + /** Мягкая тень-«пятно» под сущностью. */ function drawShadow(): HTMLCanvasElement { const { cv, ctx } = canvas(64, 32); @@ -217,6 +397,13 @@ export class Assets { case 'enemy-normal': return drawCharacter({ head: '#c08a5a', body: '#9a5a36', outline: '#3a2210', eye: '#2a1c0c' }); case 'enemy-fast': return drawCharacter({ head: '#bb3030', body: '#992222', outline: '#4a0e0e', eye: '#ffdddd', small: true }); case 'enemy-boss': return drawCharacter({ head: '#7a1414', body: '#5a0a0a', outline: '#250303', eye: '#ff4444', horns: true }); + case 'enemy-charger': return drawCharger(); + case 'enemy-tank': return drawTank(); + case 'enemy-shooter': return drawShooter(); + case 'chest': return drawChest(); + case 'pickup': return drawPickup(); + case 'fireball': return drawFireball(); + case 'beam': return drawBeam(); } }); } @@ -229,6 +416,25 @@ export class Assets { muzzle(): THREE.Texture { return this.get('muzzle', () => drawGlow('#fffbe0', 'rgba(255,200,60,0.7)'), false); } spark(): THREE.Texture { return this.get('spark', () => drawGlow('#ffffff', 'rgba(255,230,170,0.6)'), false); } puff(): THREE.Texture { return this.get('puff', () => drawGlow('rgba(220,220,230,0.9)', 'rgba(120,120,140,0.4)'), false); } + chest(): THREE.Texture { return this.get('chest', drawChest, false); } + pickup(): THREE.Texture { return this.get('pickup', drawPickup, false); } + fireball(): THREE.Texture { return this.get('fireball', drawFireball, false); } + + weaponIcon(id: WeaponId): THREE.Texture { + return this.get(`weapon-icon-${id}`, () => { + switch (id) { + case 'tears': return drawWeaponTears(); + case 'melee': return drawWeaponMelee(); + case 'shotgun': return drawWeaponShotgun(); + case 'axe': return drawWeaponAxe(); + case 'staff': return drawWeaponStaff(); + case 'whip': return drawWeaponWhip(); + case 'bomb': return drawWeaponBomb(); + case 'boomerang': return drawWeaponBoomerang(); + case 'laser': return drawWeaponLaser(); + } + }); + } dispose(): void { for (const t of this.cache.values()) t.dispose();