feat: инвентарь 2 слота, 4 новых оружия, 3 врага, босс-фазы, иконки пикапов
This commit is contained in:
@@ -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, // + доля быстрых врагов на этаж
|
||||
};
|
||||
|
||||
+352
-27
@@ -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 {
|
||||
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 (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;
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
if (this.rules.endless) {
|
||||
this.descend();
|
||||
} else {
|
||||
this.won = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,12 @@ export class Enemy {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export class KeyboardController implements InputSource {
|
||||
private held = new Set<string>();
|
||||
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',
|
||||
]);
|
||||
|
||||
+15
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+59
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Enemy['type'], SpriteKey> = {
|
||||
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();
|
||||
|
||||
+207
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user