feat: инвентарь 2 слота, 4 новых оружия, 3 врага, босс-фазы, иконки пикапов

This commit is contained in:
Volodia
2026-06-19 13:09:28 +03:00
parent d2d1d2381d
commit 01c1f71159
19 changed files with 946 additions and 74 deletions
+23
View File
@@ -124,6 +124,9 @@ export const ENEMY_STATS = {
normal: { size: 32, hp: 3, speed: 1.15, damage: 1 }, normal: { size: 32, hp: 3, speed: 1.15, damage: 1 },
fast: { size: 26, hp: 2, speed: 1.9, damage: 1 }, fast: { size: 26, hp: 2, speed: 1.9, damage: 1 },
boss: { size: 46, hp: 10, speed: 0.9, damage: 2 }, 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; } as const;
export const ENEMY = { export const ENEMY = {
@@ -147,3 +150,23 @@ export const SPAWN = {
treasureChance: 0.12, // шанс комнаты-сокровищницы treasureChance: 0.12, // шанс комнаты-сокровищницы
bossChance: 0.2, // шанс назначить комнату боссом 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, // + доля быстрых врагов на этаж
};
+352 -27
View File
@@ -1,18 +1,20 @@
import { import {
DIR, DOOR, OX, OY, TILE, COLS, ROWS, T_WALL, 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'; } from '../config';
import type { Dir } from './types'; import type { Dir } from './types';
import { Rng } from './rng'; import { Rng } from './rng';
import { dist, overlap } from './util'; import { dist, overlap } from './util';
import { Player } from './entities/Player'; import { Player } from './entities/Player';
import { Enemy } from './entities/Enemy';
import { Projectile } from './entities/Projectile'; import { Projectile } from './entities/Projectile';
import { MeleeSwing } from './entities/MeleeSwing'; import { MeleeSwing } from './entities/MeleeSwing';
import { RoomMap } from './world/RoomMap'; import { RoomMap } from './world/RoomMap';
import type { Room } from './world/Room'; import type { Room } from './world/Room';
import { collidesWall } from './systems/collision'; import { collidesWall } from './systems/collision';
import { spawnEnemies } from './systems/spawner'; import { spawnEnemies, spawnChest, pickChestWeapon } from './systems/spawner';
import { DEFAULT_RULES, type LevelRules } from './rules'; import { WeaponPickup } from './entities/WeaponPickup';
import { DEFAULT_RULES, scaleRulesForFloor, type LevelRules } from './rules';
import type { InputState } from '../input/InputState'; import type { InputState } from '../input/InputState';
import { pressingDir } from '../input/InputState'; import { pressingDir } from '../input/InputState';
@@ -36,6 +38,8 @@ export class Game {
meleeSwing: MeleeSwing | null = null; meleeSwing: MeleeSwing | null = null;
gameOver = false; gameOver = false;
won = false; won = false;
floor = 1;
inventoryOpen = false;
/** /**
* @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт». * @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт».
@@ -45,6 +49,7 @@ export class Game {
this.rules = rules; this.rules = rules;
this.rng = rng ?? new Rng(rules.seed); this.rng = rng ?? new Rng(rules.seed);
this.player = new Player(rules.player); this.player = new Player(rules.player);
this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
this.roomMap = new RoomMap(this.rng, rules); this.roomMap = new RoomMap(this.rng, rules);
this.enterRoom('up'); this.enterRoom('up');
} }
@@ -56,19 +61,33 @@ export class Game {
// ── Публичный контракт цикла ────────────────────────────── // ── Публичный контракт цикла ──────────────────────────────
/** Однократные действия (смена оружия, рестарт). Вызывать раз в кадр. */ /** Однократные действия (смена оружия, рестарт, инвентарь). Вызывать раз в кадр. */
consumeActions(input: InputState): void { 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) { 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)) { if (input.restart && (this.gameOver || this.won)) {
this.reset(); 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). */ /** Один фиксированный шаг симуляции (= 1/60 c). */
step(input: InputState): void { step(input: InputState): void {
if (this.gameOver || this.won) return; if (this.gameOver || this.won || this.inventoryOpen) return;
const room = this.curRoom; const room = this.curRoom;
const p = this.player; const p = this.player;
@@ -87,6 +106,8 @@ export class Game {
this.handleAttack(input, room, p); this.handleAttack(input, room, p);
this.updateMelee(room); this.updateMelee(room);
this.updateTears(room); this.updateTears(room);
this.updateChest(room);
this.updatePickup(room, p);
const aliveCount = this.updateEnemies(room, p); const aliveCount = this.updateEnemies(room, p);
if (this.gameOver) return; if (this.gameOver) return;
@@ -104,6 +125,7 @@ export class Game {
reset(): void { reset(): void {
this.gameOver = false; this.gameOver = false;
this.won = false; this.won = false;
this.floor = 1;
// Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз. // Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз.
this.rng = new Rng(this.rules.seed); this.rng = new Rng(this.rules.seed);
this.roomMap = new RoomMap(this.rng, this.rules); this.roomMap = new RoomMap(this.rng, this.rules);
@@ -136,6 +158,10 @@ export class Game {
if (!room.cleared && room.type !== 'spawn') { if (!room.cleared && room.type !== 'spawn') {
room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.rules); room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.rules);
// Сундук в сокровищнице.
if (room.type === 'treasure' && !room.chest) {
room.chest = spawnChest(room, this.rng);
}
// Если врагов нет (напр. сокровищница) — зачищать нечего, открываем сразу, // Если врагов нет (напр. сокровищница) — зачищать нечего, открываем сразу,
// иначе двери никогда не появятся и игрок застрянет. // иначе двери никогда не появятся и игрок застрянет.
if (room.enemies.length === 0) room.cleared = true; if (room.enemies.length === 0) room.cleared = true;
@@ -174,18 +200,45 @@ export class Game {
private handleAttack(input: InputState, room: Room, p: Player): void { private handleAttack(input: InputState, room: Room, p: Player): void {
let dir: Dir | null = null; let dir: Dir | null = null;
if (input.aimDir) dir = input.aimDir; // прицельная стрельба стрелками if (input.aimDir) dir = input.aimDir;
else if (input.attackHeld) dir = p.moveDir; // пробел — по ходу движения else if (input.attackHeld) dir = p.moveDir;
if (!dir || p.atkCD > 0) return; if (!dir || p.atkCD > 0) return;
p.facing = dir; 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]; 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 { } else {
this.meleeSwing = new MeleeSwing(p.x, p.y, dir); room.tears.push(new Projectile(p.x, p.y, nx, ny, w.projectileType));
}
} else {
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 (!e.alive || e.hitTimer > 0) continue;
if (overlap(e.box, this.meleeSwing.box)) { if (overlap(e.box, this.meleeSwing.box)) {
e.hp -= this.meleeSwing.damage; e.hp -= this.meleeSwing.damage;
e.hitTimer = MELEE.life; // защита от повторного удара тем же взмахом e.hitTimer = MELEE.life;
const [dx, dy] = DIR[this.meleeSwing.dir]; const [dx, dy] = DIR[this.meleeSwing.dir];
e.knx = dx * this.meleeSwing.kb; e.knx = dx * this.meleeSwing.kb;
e.kny = dy * 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 { private updateTears(room: Room): void {
for (const t of room.tears) { for (const t of room.tears) {
if (!t.alive) continue; if (!t.alive) continue;
// Лазерный луч: стоит на месте, жжёт врагов каждые 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.x += t.dx * t.speed;
t.y += t.dy * t.speed; t.y += t.dy * t.speed;
t.life--; t.life--;
@@ -216,28 +297,98 @@ export class Game {
const col = Math.floor((t.x - OX) / TILE); const col = Math.floor((t.x - OX) / TILE);
const row = Math.floor((t.y - OY) / TILE); const row = Math.floor((t.y - OY) / TILE);
if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) { if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) {
this.explodeBomb(room, t);
t.life = 0; t.life = 0;
continue; continue;
} }
if (room.tiles[row][col] === T_WALL) { if (room.tiles[row][col] === T_WALL) {
this.explodeBomb(room, t);
t.life = 0; t.life = 0;
continue; continue;
} }
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;
if (p.hp <= 0) { p.hp = 0; this.gameOver = true; return; }
continue;
}
} else {
// Снаряд игрока: бьёт врагов.
for (const e of room.enemies) { for (const e of room.enemies) {
if (!e.alive) continue; if (!e.alive) continue;
if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + t.r) { if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + t.r) {
e.hp -= t.damage; e.hp -= t.damage;
e.hitTimer = ENEMY.hitFlash; 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; t.life = 0;
this.explodeBomb(room, t);
break; 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); 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 { private updateEnemies(room: Room, p: Player): number {
let aliveCount = 0; let aliveCount = 0;
const newEnemies: Enemy[] = [];
for (const e of room.enemies) { for (const e of room.enemies) {
if (!e.alive) continue; if (!e.alive) continue;
@@ -245,9 +396,17 @@ export class Game {
if (e.hitTimer > 0) e.hitTimer--; if (e.hitTimer > 0) e.hitTimer--;
// Фаза отбрасывания: летит по инерции, ИИ не работает. Коллизии // Горение: урон каждые fireInterval тиков.
// проверяем пораздельно по осям — иначе кнокбэк (до ~4.5 тайла) if (e.burnTimer > 0) {
// пробивал стену в 1 тайл, и враг застревал снаружи навсегда (софт-лок). 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) { if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) {
e.x += e.knx * 3; e.x += e.knx * 3;
if (collidesWall(e.box, room)) 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.knx = 0;
e.kny = 0; e.kny = 0;
// Преследование игрока. if (e.type === 'boss' && this.milestoneBossFloor(this.floor)) {
const dx = p.x - e.x; this.updateMilestoneBoss(e, room, p, newEnemies);
const dy = p.y - e.y; } else if (e.type === 'shooter') {
const d = Math.hypot(dx, dy); this.updateShooter(e, room, p);
if (d > 0 && d < ENEMY.aggroRange) { } else if (e.type === 'charger') {
const mx = (dx / d) * e.speed; this.updateCharger(e, room, p);
const my = (dy / d) * e.speed; } else {
e.x += mx; this.updateChaser(e, room, p);
if (collidesWall(e.box, room)) e.x -= mx;
e.y += my;
if (collidesWall(e.box, room)) e.y -= my;
} }
// Контактный урон по игроку. // Контактный урон по игроку.
@@ -287,9 +443,150 @@ export class Game {
} }
} }
room.enemies.push(...newEnemies);
return aliveCount; 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 { 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 { private checkWin(): void {
for (const room of this.roomMap.rooms.values()) { for (const room of this.roomMap.rooms.values()) {
if (room.type === 'boss' && room.cleared) { if (room.type === 'boss' && room.cleared) {
if (this.rules.endless) {
this.descend();
} else {
this.won = true; this.won = true;
}
return; return;
} }
} }
+24
View File
@@ -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;
}
}
+6
View File
@@ -22,6 +22,12 @@ export class Enemy {
kny = 0; // отбрасывание по Y kny = 0; // отбрасывание по Y
hitTimer = 0; // мигание при попадании (шаги) hitTimer = 0; // мигание при попадании (шаги)
atkTimer = 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, * mods — множители из правил уровня (см. core/rules.ts). По умолчанию 1,
+13 -9
View File
@@ -1,23 +1,27 @@
import { MELEE, DIR } from '../../config'; import { MELEE, DIR } from '../../config';
import type { Dir, Box } from '../types'; import type { Dir, Box } from '../types';
/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на MELEE.life шагов. */ /** Взмах ближнего боя: прямоугольный хитбокс перед игроком на life шагов. */
export class MeleeSwing { export class MeleeSwing {
readonly dir: Dir; readonly dir: Dir;
life = MELEE.life; life = MELEE.life;
readonly damage = MELEE.damage; readonly damage: number;
readonly kb = MELEE.knockback; readonly kb: number;
readonly box: Box; 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; 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]; const [dx, dy] = DIR[dir];
this.box = { this.box = {
x: x + (dx > 0 ? d : dx < 0 ? -d - s : -s / 2), x: x + (dx > 0 ? d : dx < 0 ? -d - size : -size / 2),
y: y + (dy > 0 ? d : dy < 0 ? -d - s : -s / 2), y: y + (dy > 0 ? d : dy < 0 ? -d - size : -size / 2),
w: s, w: size,
h: s, h: size,
}; };
} }
+16
View File
@@ -1,4 +1,5 @@
import { PLAYER, MODE_RANGED } from '../../config'; import { PLAYER, MODE_RANGED } from '../../config';
import { WEAPONS, type WeaponId, type WeaponDef } from '../weapons';
import type { CombatMode, Box, Dir } from '../types'; import type { CombatMode, Box, Dir } from '../types';
/** /**
@@ -23,6 +24,11 @@ export class Player {
invTimer = 0; // неуязвимость (шаги) invTimer = 0; // неуязвимость (шаги)
transCD = 0; // блок перехода между комнатами (шаги) transCD = 0; // блок перехода между комнатами (шаги)
/** Ровно 2 слота под оружие. */
weapons: [WeaponDef, WeaponDef] = [WEAPONS.tears, WEAPONS.melee];
/** 0 или 1 — какой слот сейчас экипирован. */
equipped: 0 | 1 = 0;
/** Переопределения из правил уровня; по умолчанию — баланс из config. */ /** Переопределения из правил уровня; по умолчанию — баланс из config. */
constructor(rules: { maxHp?: number; speed?: number } = {}) { constructor(rules: { maxHp?: number; speed?: number } = {}) {
this.maxHp = rules.maxHp ?? PLAYER.maxHp; 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 }; 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 { place(x: number, y: number): void {
this.x = this.prevX = x; this.x = this.prevX = x;
+8 -4
View File
@@ -1,6 +1,7 @@
import { PROJECTILE } from '../../config'; import { PROJECTILE } from '../../config';
import type { ProjectileType } from '../types';
/** Снаряд игрока («слеза»). Летит по прямой, пока не врежется или не истечёт life. */ /** Снаряд. Летит по прямой, пока не врежется или не истечёт life. */
export class Projectile { export class Projectile {
x: number; x: number;
y: number; y: number;
@@ -8,16 +9,19 @@ export class Projectile {
prevY: number; prevY: number;
dx: number; dx: number;
dy: number; dy: number;
readonly type: ProjectileType;
readonly r = PROJECTILE.radius; readonly r = PROJECTILE.radius;
readonly speed = PROJECTILE.speed; speed = PROJECTILE.speed;
readonly damage = PROJECTILE.damage; damage = PROJECTILE.damage;
life = PROJECTILE.life; 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.x = this.prevX = x;
this.y = this.prevY = y; this.y = this.prevY = y;
this.dx = dx; this.dx = dx;
this.dy = dy; this.dy = dy;
this.type = type;
} }
get alive(): boolean { get alive(): boolean {
+20
View File
@@ -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 };
}
}
+37 -1
View File
@@ -10,7 +10,7 @@
* Геометрия (размер тайла/комнаты, геометрия дверей) остаётся в config.ts: это * Геометрия (размер тайла/комнаты, геометрия дверей) остаётся в 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 { export interface LevelRules {
/** Машинный id (для сохранений/выбора). */ /** Машинный id (для сохранений/выбора). */
@@ -21,6 +21,8 @@ export interface LevelRules {
description: string; description: string;
/** Фиксированный seed генерации. undefined → случайный каждый забег. */ /** Фиксированный seed генерации. undefined → случайный каждый забег. */
seed?: number; seed?: number;
/** Бесконечный спуск: после босса — новый этаж с усилением, а не победа. */
endless?: boolean;
/** Параметры генерации карты. */ /** Параметры генерации карты. */
map: { map: {
@@ -91,4 +93,38 @@ export const PRESETS: LevelRules[] = [
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed }, player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 }, 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,
};
}
+26 -2
View File
@@ -1,9 +1,11 @@
import { OX, OY, TILE, COLS, ROWS, DOOR, SPAWN } from '../../config'; import { OX, OY, TILE, COLS, ROWS, DOOR, SPAWN } from '../../config';
import { Enemy } from '../entities/Enemy'; import { Enemy } from '../entities/Enemy';
import { Chest } from '../entities/Chest';
import { dist } from '../util'; import { dist } from '../util';
import type { Room } from '../world/Room'; import type { Room } from '../world/Room';
import type { Dir, EnemyType } from '../types'; import type { Dir, EnemyType } from '../types';
import type { Rng } from '../rng'; import type { Rng } from '../rng';
import type { WeaponId } from '../weapons';
import { DEFAULT_RULES, type LevelRules } from '../rules'; import { DEFAULT_RULES, type LevelRules } from '../rules';
function isSpawnSpotClear( function isSpawnSpotClear(
@@ -29,6 +31,17 @@ function isSpawnSpotClear(
* берутся из правил уровня (rules). Возвращает массив — вызывающий код кладёт * берутся из правил уровня (rules). Возвращает массив — вызывающий код кладёт
* его в room.enemies. * его в 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( export function spawnEnemies(
room: Room, room: Room,
entryDir: Dir, entryDir: Dir,
@@ -50,8 +63,7 @@ export function spawnEnemies(
const doorY = OY + door.cy * TILE + TILE / 2; const doorY = OY + door.cy * TILE + TILE / 2;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const type: EnemyType = const type: EnemyType = pickEnemyType(room, rng, er.fastChance);
room.type === 'boss' ? 'boss' : rng.chance(er.fastChance) ? 'fast' : 'normal';
const mods = { const mods = {
hpMul: er.hpMul * (type === 'boss' ? er.bossHpMul : 1), hpMul: er.hpMul * (type === 'boss' ? er.bossHpMul : 1),
speedMul: er.speedMul, speedMul: er.speedMul,
@@ -72,3 +84,15 @@ export function spawnEnemies(
return enemies; 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);
}
+2 -1
View File
@@ -3,7 +3,8 @@
export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss'; export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss';
export type Dir = 'up' | 'down' | 'left' | 'right'; export type Dir = 'up' | 'down' | 'left' | 'right';
export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE 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) для коллизий. */ /** Прямоугольник (axis-aligned bounding box) для коллизий. */
export interface Box { export interface Box {
+35
View File
@@ -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<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 },
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 },
};
+4
View File
@@ -2,6 +2,8 @@ import type { RoomType, Doors } from '../types';
import { buildTiles } from './tiles'; import { buildTiles } from './tiles';
import type { Enemy } from '../entities/Enemy'; import type { Enemy } from '../entities/Enemy';
import type { Projectile } from '../entities/Projectile'; import type { Projectile } from '../entities/Projectile';
import type { Chest } from '../entities/Chest';
import type { WeaponPickup } from '../entities/WeaponPickup';
/** /**
* Комната дандженa. Хранит свой тип, набор дверей, состояние «зачищена/ * Комната дандженa. Хранит свой тип, набор дверей, состояние «зачищена/
@@ -17,6 +19,8 @@ export class Room {
cleared = false; cleared = false;
enemies: Enemy[] = []; enemies: Enemy[] = [];
tears: Projectile[] = []; tears: Projectile[] = [];
chest: Chest | null = null;
pickup: WeaponPickup | null = null;
tiles: number[][]; tiles: number[][];
constructor(c: number, r: number, type: RoomType) { constructor(c: number, r: number, type: RoomType) {
+2
View File
@@ -17,6 +17,7 @@ export interface InputState {
attackHeld: boolean; // атака «по ходу движения» (пробел) attackHeld: boolean; // атака «по ходу движения» (пробел)
toggleWeapon: boolean; // сменить оружие (однократно) toggleWeapon: boolean; // сменить оружие (однократно)
restart: boolean; // рестарт на экране конца игры (однократно) restart: boolean; // рестарт на экране конца игры (однократно)
openInventory: boolean; // открыть инвентарь (однократно, E)
} }
/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */ /** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */
@@ -33,6 +34,7 @@ export function emptyInput(): InputState {
attackHeld: false, attackHeld: false,
toggleWeapon: false, toggleWeapon: false,
restart: false, restart: false,
openInventory: false,
}; };
} }
+6 -1
View File
@@ -18,6 +18,7 @@ export class KeyboardController implements InputSource {
private held = new Set<string>(); private held = new Set<string>();
private toggleWeaponEdge = false; private toggleWeaponEdge = false;
private restartEdge = false; private restartEdge = false;
private inventoryEdge = false;
private attached = false; private attached = false;
private onKeyDown = (e: KeyboardEvent): void => { private onKeyDown = (e: KeyboardEvent): void => {
@@ -26,6 +27,7 @@ export class KeyboardController implements InputSource {
if (!this.held.has(c)) { if (!this.held.has(c)) {
if (c === 'Tab' || c === 'KeyQ') this.toggleWeaponEdge = true; if (c === 'Tab' || c === 'KeyQ') this.toggleWeaponEdge = true;
if (c === 'KeyR') this.restartEdge = true; if (c === 'KeyR') this.restartEdge = true;
if (c === 'KeyE') this.inventoryEdge = true;
} }
this.held.add(c); this.held.add(c);
if (PREVENT.has(c)) e.preventDefault(); if (PREVENT.has(c)) e.preventDefault();
@@ -49,6 +51,7 @@ export class KeyboardController implements InputSource {
this.held.clear(); this.held.clear();
this.toggleWeaponEdge = false; this.toggleWeaponEdge = false;
this.restartEdge = false; this.restartEdge = false;
this.inventoryEdge = false;
} }
/** Подписаться на события окна. Вызывается один раз при старте. */ /** Подписаться на события окна. Вызывается один раз при старте. */
@@ -84,15 +87,17 @@ export class KeyboardController implements InputSource {
attackHeld: down('Space'), attackHeld: down('Space'),
toggleWeapon: this.toggleWeaponEdge, toggleWeapon: this.toggleWeaponEdge,
restart: this.restartEdge, restart: this.restartEdge,
openInventory: this.inventoryEdge,
}; };
this.toggleWeaponEdge = false; this.toggleWeaponEdge = false;
this.restartEdge = false; this.restartEdge = false;
this.inventoryEdge = false;
return snapshot; return snapshot;
} }
} }
/** Физические клавиши (e.code), у которых гасим поведение браузера (скролл/таб). */ /** Физические клавиши (e.code), у которых гасим поведение браузера (скролл/таб). */
const PREVENT = new Set([ const PREVENT = new Set([
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab', 'KeyE',
]); ]);
+15
View File
@@ -58,9 +58,24 @@ function boot(): void {
// Esc во время игры — вернуться к выбору уровня (по физической клавише). // Esc во время игры — вернуться к выбору уровня (по физической клавише).
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.code === 'Escape' && loop) { if (e.code === 'Escape' && loop) {
const g = (window as Window & { game?: Game }).game;
if (g?.inventoryOpen) {
g.inventoryOpen = false;
e.preventDefault();
return;
}
e.preventDefault(); e.preventDefault();
toMenu(); 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();
}
}
}); });
} }
+59 -11
View File
@@ -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 { Game } from '../core/Game';
import type { Renderer } from './Renderer'; import type { Renderer } from './Renderer';
@@ -36,6 +36,11 @@ export class HudOverlay implements Renderer {
const ctx = this.ctx; const ctx = this.ctx;
ctx.clearRect(0, 0, CW, CH); ctx.clearRect(0, 0, CW, CH);
if (game.inventoryOpen) {
this.drawInventory(game);
return;
}
this.drawHud(game); this.drawHud(game);
this.drawMinimap(game); this.drawMinimap(game);
@@ -55,22 +60,21 @@ export class HudOverlay implements Renderer {
// Здоровье: сердечки (2 HP = сердце), с фолбэком на полосу. // Здоровье: сердечки (2 HP = сердце), с фолбэком на полосу.
const healthBottom = this.drawHealth(p.hp, p.maxHp); const healthBottom = this.drawHealth(p.hp, p.maxHp);
// Название текущего уровня (правил). // Название пресета и номер этажа (для бесконечного спуска).
ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace'; 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 my = CH - 46;
const ranged = p.mode === MODE_RANGED; const w = p.currentWeapon;
const mText = ranged ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ'; const wCol = w.type === 'ranged' ? '#4488cc' : '#cc6644';
const mCol = ranged ? '#4488cc' : '#cc6644';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34); 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.strokeStyle = wCol; 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.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); ctx.fillStyle = '#555'; ctx.font = '11px monospace'; ctx.fillText('[Tab] сменить оружие', CW / 2, my - 26);
// Иконка оружия слева в рамке (если ассет есть). const icon = this.img(w.type === 'ranged' ? 'icon-ranged' : 'icon-melee');
const icon = this.img(ranged ? 'icon-ranged' : 'icon-melee');
if (icon) ctx.drawImage(icon, CW / 2 - 90, my - 14, 26, 26); 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.fillStyle = '#666'; ctx.font = '14px monospace';
ctx.fillText('[Esc] в меню', CW / 2, CH / 2 + 68); 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);
}
} }
+80 -6
View File
@@ -8,6 +8,7 @@ import type { Game } from '../core/Game';
import type { Room } from '../core/world/Room'; import type { Room } from '../core/world/Room';
import type { Enemy } from '../core/entities/Enemy'; import type { Enemy } from '../core/entities/Enemy';
import type { Projectile } from '../core/entities/Projectile'; import type { Projectile } from '../core/entities/Projectile';
import type { WeaponId } from '../core/weapons';
import type { Renderer } from './Renderer'; import type { Renderer } from './Renderer';
import { DEFAULT_THEME, type Theme } from './theme'; import { DEFAULT_THEME, type Theme } from './theme';
import { Assets, type SpriteKey } from './assets'; 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 playerMat: Record<'ranged' | 'melee', THREE.MeshBasicMaterial>;
private readonly enemyMatKey: Record<Enemy['type'], SpriteKey> = { private readonly enemyMatKey: Record<Enemy['type'], SpriteKey> = {
normal: 'enemy-normal', fast: 'enemy-fast', boss: 'enemy-boss', 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 readonly effects: Effect[] = [];
private lastAtkCD = 0; 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) { constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) {
this.theme = theme; this.theme = theme;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
@@ -130,6 +136,8 @@ export class ThreeRenderer implements Renderer {
this.syncEnemies(room, alpha); this.syncEnemies(room, alpha);
this.syncTears(room, alpha); this.syncTears(room, alpha);
this.syncSwing(game); this.syncSwing(game);
this.syncChest(room);
this.syncPickup(room);
this.updateEffects(); this.updateEffects();
this.renderer.render(this.scene, this.camera); 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 x = lerp(e.prevX, e.x, alpha);
const z = lerp(e.prevY, e.y, 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 w = e.w * SPRITE_SCALE * pop;
const h = w * SPRITE_ASPECT; const h = w * SPRITE_ASPECT;
v.sprite.scale.set(w, h, 1); v.sprite.scale.set(w, h, 1);
v.sprite.position.set(x, h / 2, z); v.sprite.position.set(x, h / 2, z);
this.placeShadow(v.shadow, x, z, e.w); this.placeShadow(v.shadow, x, z, e.w);
// Искра в момент попадания (hitTimer вырос). // Искра в момент попадания (hitTimer вырос).
if (e.hitTimer > v.lastHit) { 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; 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); live.add(t);
let mesh = this.tearMeshes.get(t); let mesh = this.tearMeshes.get(t);
if (!mesh) { 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({ 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); mesh.scale.set(s, s, 1);
this.scene.add(mesh); this.scene.add(mesh);
this.tearMeshes.set(t, 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) { for (const [t, mesh] of this.tearMeshes) {
if (live.has(t)) continue; 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); (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( 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 m of this.tearMeshes.values()) (m.material as THREE.Material).dispose();
for (const fx of this.effects) (fx.mesh.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(); (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.floorMat.dispose();
this.wallMat.dispose(); this.wallMat.dispose();
this.shadowMat.dispose(); this.shadowMat.dispose();
+207 -1
View File
@@ -1,4 +1,5 @@
import * as THREE from 'three'; import * as THREE from 'three';
import type { WeaponId } from '../core/weapons';
/** /**
* assets.ts — поставщик текстур. Каждая текстура грузится из * assets.ts — поставщик текстур. Каждая текстура грузится из
@@ -14,7 +15,9 @@ import * as THREE from 'three';
export type SpriteKey = export type SpriteKey =
| 'player-ranged' | 'player-melee' | '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 } { function canvas(w: number, h: number): { cv: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
const cv = document.createElement('canvas'); const cv = document.createElement('canvas');
@@ -166,6 +169,183 @@ function drawDoor(open: boolean): HTMLCanvasElement {
return cv; 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 { function drawShadow(): HTMLCanvasElement {
const { cv, ctx } = canvas(64, 32); 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-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-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-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); } 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); } 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); } 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 { dispose(): void {
for (const t of this.cache.values()) t.dispose(); for (const t of this.cache.values()) t.dispose();