feat(render): псевдо-3D, всегда видимые двери, ассеты+эффекты, русификация
- Рендер переведён в псевдо-3D: наклонная PerspectiveCamera, пол лежит плашмя, персонажи/враги — вертикальные спрайты-биллборды, стены с высотой. Починен баг: пол создавался без поворота и стоял вертикально → «вывернутая» перспектива. - Двери видны всегда: закрыты (засов) во время боя, открытый проём после зачистки. - render/assets.ts: процедурные текстуры (спрайты персонажей/врагов, пол, стены, двери, снаряд, тень) + эффекты оружия (вспышка из дула, искры, облачко гибели). - Светлее палитра — исправлено «тёмное на тёмном». - Игра переименована в «Биндим Фигняшку»; полная русификация UI (ДАЛЬНИЙ/БЛИЖНИЙ, ИГРА ОКОНЧЕНА, ПОБЕДА); клавиши-подсказки оставлены латиницей. - Ввод по event.code → WASD/Q/R работают в любой раскладке (вкл. русскую). - theme.ts урезан до реально используемых тинтов (bg/swing/flash). - docs/ASSET_BRIEF.md — бриф и промпт на полную художку; доки синхронизированы. - Ядро (src/core) не тронуто — раунд чисто render/UI. 27 тестов + типы зелёные. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,14 @@ import type { Dir } from '../core/types';
|
||||
* Раскладка: WASD — движение, стрелки — прицельная стрельба, пробел —
|
||||
* атака по ходу движения, Tab/Q — смена оружия, R — рестарт.
|
||||
*
|
||||
* Контроллер держит набор зажатых клавиш и «защёлкивает» однократные
|
||||
* действия (смена оружия/рестарт). Раз в кадр вызывается poll(), который
|
||||
* собирает InputState и сбрасывает однократные флаги.
|
||||
* ВАЖНО: используем `event.code` (ФИЗИЧЕСКАЯ клавиша), а не `event.key`.
|
||||
* `code` не зависит от раскладки, поэтому WASD/Q/R работают и в русской
|
||||
* раскладке (где те же клавиши дают «цфыв»/«й»/«к»). Стрелки/Tab/пробел в
|
||||
* `code` называются ArrowUp/Tab/Space.
|
||||
*
|
||||
* Контроллер держит набор зажатых клавиш и «защёлкивает» однократные действия
|
||||
* (смена оружия/рестарт). Раз в кадр вызывается poll(), который собирает
|
||||
* InputState и сбрасывает однократные флаги.
|
||||
*/
|
||||
export class KeyboardController {
|
||||
private held = new Set<string>();
|
||||
@@ -16,18 +21,18 @@ export class KeyboardController {
|
||||
private attached = false;
|
||||
|
||||
private onKeyDown = (e: KeyboardEvent): void => {
|
||||
const k = e.key;
|
||||
const c = e.code;
|
||||
// Однократные действия ловим по факту нажатия (не по удержанию).
|
||||
if (!this.held.has(k)) {
|
||||
if (k === 'Tab' || k === 'q' || k === 'Q') this.toggleWeaponEdge = true;
|
||||
if (k === 'r' || k === 'R') this.restartEdge = true;
|
||||
if (!this.held.has(c)) {
|
||||
if (c === 'Tab' || c === 'KeyQ') this.toggleWeaponEdge = true;
|
||||
if (c === 'KeyR') this.restartEdge = true;
|
||||
}
|
||||
this.held.add(k);
|
||||
if (PREVENT.has(k)) e.preventDefault();
|
||||
this.held.add(c);
|
||||
if (PREVENT.has(c)) e.preventDefault();
|
||||
};
|
||||
|
||||
private onKeyUp = (e: KeyboardEvent): void => {
|
||||
this.held.delete(e.key);
|
||||
this.held.delete(e.code);
|
||||
};
|
||||
|
||||
private onBlur = (): void => {
|
||||
@@ -57,14 +62,14 @@ export class KeyboardController {
|
||||
|
||||
/** Собрать снимок ввода и сбросить однократные флаги. */
|
||||
poll(): InputState {
|
||||
const down = (k: string) => this.held.has(k);
|
||||
const down = (code: string) => this.held.has(code);
|
||||
|
||||
let moveX = 0;
|
||||
let moveY = 0;
|
||||
if (down('w') || down('W')) moveY -= 1;
|
||||
if (down('s') || down('S')) moveY += 1;
|
||||
if (down('a') || down('A')) moveX -= 1;
|
||||
if (down('d') || down('D')) moveX += 1;
|
||||
if (down('KeyW')) moveY -= 1;
|
||||
if (down('KeyS')) moveY += 1;
|
||||
if (down('KeyA')) moveX -= 1;
|
||||
if (down('KeyD')) moveX += 1;
|
||||
|
||||
let aimDir: Dir | null = null;
|
||||
if (down('ArrowUp')) aimDir = 'up';
|
||||
@@ -76,7 +81,7 @@ export class KeyboardController {
|
||||
moveX,
|
||||
moveY,
|
||||
aimDir,
|
||||
attackHeld: down(' ') || down('Spacebar'),
|
||||
attackHeld: down('Space'),
|
||||
toggleWeapon: this.toggleWeaponEdge,
|
||||
restart: this.restartEdge,
|
||||
};
|
||||
@@ -87,7 +92,7 @@ export class KeyboardController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Клавиши, у которых гасим стандартное поведение браузера (скролл и т.п.). */
|
||||
/** Физические клавиши (e.code), у которых гасим поведение браузера (скролл/таб). */
|
||||
const PREVENT = new Set([
|
||||
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'Spacebar', 'Tab',
|
||||
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab',
|
||||
]);
|
||||
|
||||
+2
-2
@@ -55,9 +55,9 @@ function boot(): void {
|
||||
const menu = new StartMenu(menuEl, PRESETS, startGame);
|
||||
menu.show();
|
||||
|
||||
// Esc во время игры — вернуться к выбору уровня.
|
||||
// Esc во время игры — вернуться к выбору уровня (по физической клавише).
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && loop) {
|
||||
if (e.code === 'Escape' && loop) {
|
||||
e.preventDefault();
|
||||
toMenu();
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ export class HudOverlay implements Renderer {
|
||||
this.drawHud(game);
|
||||
this.drawMinimap(game);
|
||||
|
||||
if (game.gameOver) this.drawOverlay('#c33', 'GAME OVER');
|
||||
else if (game.won) this.drawOverlay('#3c3', 'VICTORY');
|
||||
if (game.gameOver) this.drawOverlay('#c33', 'ИГРА ОКОНЧЕНА');
|
||||
else if (game.won) this.drawOverlay('#3c3', 'ПОБЕДА');
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -58,7 +58,7 @@ export class HudOverlay implements Renderer {
|
||||
// Индикатор режима боя.
|
||||
const my = CH - 46;
|
||||
const ranged = p.mode === MODE_RANGED;
|
||||
const mText = ranged ? 'RANGED' : 'MELEE';
|
||||
const mText = ranged ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ';
|
||||
const mCol = ranged ? '#4488cc' : '#cc6644';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
|
||||
|
||||
+258
-113
@@ -1,7 +1,7 @@
|
||||
import * as THREE from 'three';
|
||||
import {
|
||||
CW, CH, OX, OY, TILE, COLS, ROWS,
|
||||
T_WALL, T_DOOR, MODE_RANGED, PROJECTILE,
|
||||
CW, CH, OX, OY, TILE, COLS, ROWS, RW, RH,
|
||||
DIR, MODE_RANGED, PROJECTILE, MELEE,
|
||||
} from '../config';
|
||||
import { lerp } from '../core/util';
|
||||
import type { Game } from '../core/Game';
|
||||
@@ -10,137 +10,213 @@ import type { Enemy } from '../core/entities/Enemy';
|
||||
import type { Projectile } from '../core/entities/Projectile';
|
||||
import type { Renderer } from './Renderer';
|
||||
import { DEFAULT_THEME, type Theme } from './theme';
|
||||
|
||||
/** Z-слои: больше значение — ближе к камере (рисуется поверх). */
|
||||
const Z = { floor: 0, wall: 1, door: 0.5, swing: 4, entity: 5, tear: 6 };
|
||||
import { Assets, type SpriteKey } from './assets';
|
||||
|
||||
/**
|
||||
* Все материалы — DoubleSide. Наша ортокамера переворачивает ось Y
|
||||
* (top=0 сверху), из-за чего инвертируется порядок вершин и при обычном
|
||||
* отсечении задних граней плоскости становятся невидимыми. DoubleSide
|
||||
* рисует грань с обеих сторон — для плоского 2D это правильный выбор.
|
||||
*/
|
||||
function flatMat(params: THREE.MeshBasicMaterialParameters = {}): THREE.MeshBasicMaterial {
|
||||
return new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, ...params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендер мира на three.js с ОРТОГРАФИЧЕСКОЙ камерой: 3D-движок, но картинка
|
||||
* плоская 2D-сверху (как у настоящего Isaac). Мировые координаты совпадают
|
||||
* с пиксельными координатами логики (x вправо, y вниз), поэтому вся
|
||||
* математика ядра остаётся валидной без пересчётов.
|
||||
* Псевдо-3D рендер «как в Isaac»: наклонная перспективная камера, пол лежит
|
||||
* плоско, а персонажи/враги — ВЕРТИКАЛЬНЫЕ спрайты-биллборды, стоящие на полу.
|
||||
*
|
||||
* Управление ресурсами:
|
||||
* • геометрии-«единицы» (unitPlane/unitCircle) общие и переиспользуются
|
||||
* масштабированием — не плодим геометрии;
|
||||
* • тайлы комнаты пересобираются ТОЛЬКО при смене комнаты;
|
||||
* • меши сущностей создаются/удаляются по мере появления/исчезновения
|
||||
* (mark-and-sweep), их персональные материалы корректно dispose-ятся.
|
||||
* КАРТА КООРДИНАТ: игровая логика остаётся 2D-сверху (x, y). В 3D мы кладём
|
||||
* y на ось Z: мировая точка = (x, высота, y). Пол — плоскость Y=0; вверх — +Y.
|
||||
* Поэтому ВСЯ математика и КОЛЛИЗИИ ядра без изменений: хитбоксы по-прежнему на
|
||||
* полу (footprint спрайта), псевдо-3D — чисто визуальный слой.
|
||||
*
|
||||
* Камера фиксированная на комнату (как в Isaac), кадрирует всю комнату.
|
||||
*/
|
||||
|
||||
// ── Параметры вида (крути для настройки картинки) ─────────────
|
||||
const WALL_H = 38; // высота стен
|
||||
const SPRITE_SCALE = 1.5; // ширина спрайта = размер хитбокса × это
|
||||
const SPRITE_ASPECT = 64 / 48; // высота/ширина спрайта (из канваса ассета)
|
||||
const SHADOW_Y = 0.6; // тень чуть над полом (без z-fighting)
|
||||
const TEAR_Y = 13; // высота полёта снаряда
|
||||
const GAP = 3 * TILE; // ширина дверного проёма (3 тайла)
|
||||
|
||||
type EnemyVisual = { sprite: THREE.Mesh; shadow: THREE.Mesh; lastHit: number };
|
||||
type Effect = { mesh: THREE.Mesh; life: number; max: number; vy: number; grow: number };
|
||||
|
||||
export class ThreeRenderer implements Renderer {
|
||||
private readonly renderer: THREE.WebGLRenderer;
|
||||
private readonly scene = new THREE.Scene();
|
||||
private readonly camera: THREE.OrthographicCamera;
|
||||
private readonly camera: THREE.PerspectiveCamera;
|
||||
private readonly assets = new Assets();
|
||||
private readonly theme: Theme;
|
||||
|
||||
// Общие геометрии-единицы (масштабируем под размер сущности).
|
||||
private readonly unitPlane = new THREE.PlaneGeometry(1, 1);
|
||||
private readonly unitCircle = new THREE.CircleGeometry(0.5, 24);
|
||||
// Общие геометрии.
|
||||
private readonly vGeo = new THREE.PlaneGeometry(1, 1); // вертикальный спрайт/стена
|
||||
private readonly flatGeo = new THREE.PlaneGeometry(1, 1); // лежит на полу (повёрнут)
|
||||
|
||||
// Общие материалы тайлов (без пер-тайлового мигания — можно шарить).
|
||||
private readonly tileMats: Record<string, THREE.MeshBasicMaterial>;
|
||||
// Общие материалы.
|
||||
private readonly floorMat: THREE.MeshBasicMaterial;
|
||||
private readonly wallMat: THREE.MeshBasicMaterial;
|
||||
private readonly shadowMat: THREE.MeshBasicMaterial;
|
||||
private readonly playerMat: Record<'ranged' | 'melee', THREE.MeshBasicMaterial>;
|
||||
private readonly enemyMatKey: Record<Enemy['type'], SpriteKey> = {
|
||||
normal: 'enemy-normal', fast: 'enemy-fast', boss: 'enemy-boss',
|
||||
};
|
||||
|
||||
// Группа статичных тайлов текущей комнаты.
|
||||
// Группа статичной геометрии комнаты (пол + стены + двери).
|
||||
private roomGroup = new THREE.Group();
|
||||
private renderedRoom: Room | null = null;
|
||||
private renderedCleared = false;
|
||||
|
||||
// Динамические меши с персональными материалами.
|
||||
// Динамика.
|
||||
private readonly playerMesh: THREE.Mesh;
|
||||
private readonly enemyMeshes = new Map<Enemy, THREE.Mesh>();
|
||||
private readonly playerShadow: THREE.Mesh;
|
||||
private readonly enemyVisuals = new Map<Enemy, EnemyVisual>();
|
||||
private readonly tearMeshes = new Map<Projectile, THREE.Mesh>();
|
||||
private readonly swingMesh: THREE.Mesh;
|
||||
|
||||
private readonly theme: Theme;
|
||||
private readonly effects: Effect[] = [];
|
||||
private lastAtkCD = 0;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) {
|
||||
this.theme = theme;
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
this.renderer.setSize(CW, CH, false);
|
||||
this.renderer.setClearColor(this.theme.bg, 1);
|
||||
this.renderer.setClearColor(theme.bg, 1);
|
||||
|
||||
// Ортокамера: world (0,0) — верхний левый угол, (CW,CH) — нижний правый.
|
||||
this.camera = new THREE.OrthographicCamera(0, CW, 0, CH, 0.1, 1000);
|
||||
this.camera.position.z = 100;
|
||||
// Наклонная камера, кадрирует комнату с юга-сверху.
|
||||
this.camera = new THREE.PerspectiveCamera(44, CW / CH, 1, 4000);
|
||||
const cx = OX + RW / 2;
|
||||
const cz = OY + RH / 2;
|
||||
// Наклонный «исааковский» ракурс: камера приподнята и отодвинута на юг.
|
||||
this.camera.position.set(cx, 650, cz + 560);
|
||||
this.camera.lookAt(cx, 40, cz);
|
||||
|
||||
this.tileMats = {
|
||||
floorA: flatMat({ color: this.theme.floorA }),
|
||||
floorB: flatMat({ color: this.theme.floorB }),
|
||||
wall: flatMat({ color: this.theme.wall }),
|
||||
door: flatMat({ color: this.theme.door }),
|
||||
};
|
||||
// Материалы.
|
||||
const floorTex = this.assets.floor();
|
||||
floorTex.wrapS = floorTex.wrapT = THREE.RepeatWrapping;
|
||||
floorTex.repeat.set(COLS, ROWS);
|
||||
this.floorMat = new THREE.MeshBasicMaterial({ map: floorTex });
|
||||
|
||||
const wallTex = this.assets.wall();
|
||||
wallTex.wrapS = wallTex.wrapT = THREE.RepeatWrapping;
|
||||
wallTex.repeat.set(4, 1);
|
||||
this.wallMat = new THREE.MeshBasicMaterial({ map: wallTex, side: THREE.DoubleSide });
|
||||
|
||||
this.shadowMat = new THREE.MeshBasicMaterial({
|
||||
map: this.assets.shadow(), transparent: true, depthWrite: false,
|
||||
});
|
||||
|
||||
const spriteMat = (key: SpriteKey) =>
|
||||
new THREE.MeshBasicMaterial({ map: this.assets.sprite(key), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide });
|
||||
this.playerMat = { ranged: spriteMat('player-ranged'), melee: spriteMat('player-melee') };
|
||||
|
||||
this.scene.add(this.roomGroup);
|
||||
|
||||
this.playerMesh = new THREE.Mesh(this.unitPlane, flatMat({ color: this.theme.playerRanged }));
|
||||
this.playerMesh.position.z = Z.entity;
|
||||
this.playerShadow = this.flatMesh(this.shadowMat);
|
||||
this.scene.add(this.playerShadow);
|
||||
this.playerMesh = new THREE.Mesh(this.vGeo, this.playerMat.ranged);
|
||||
this.scene.add(this.playerMesh);
|
||||
|
||||
this.swingMesh = new THREE.Mesh(
|
||||
this.unitPlane,
|
||||
flatMat({ color: this.theme.swing, transparent: true, opacity: 0.45 }),
|
||||
this.swingMesh = this.flatMesh(
|
||||
new THREE.MeshBasicMaterial({
|
||||
map: this.assets.spark(), color: new THREE.Color(this.theme.swing),
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
}),
|
||||
);
|
||||
this.swingMesh.position.z = Z.swing;
|
||||
this.swingMesh.visible = false;
|
||||
this.scene.add(this.swingMesh);
|
||||
}
|
||||
|
||||
render(game: Game, alpha: number): void {
|
||||
const room = game.curRoom;
|
||||
if (room !== this.renderedRoom) this.buildRoom(room);
|
||||
if (room !== this.renderedRoom || room.cleared !== this.renderedCleared) {
|
||||
this.buildRoom(room);
|
||||
}
|
||||
|
||||
this.syncPlayer(game, alpha);
|
||||
this.syncEnemies(room, alpha);
|
||||
this.syncTears(room, alpha);
|
||||
this.syncSwing(game);
|
||||
this.updateEffects();
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
// ── Статичная геометрия комнаты ───────────────────────────
|
||||
// ── Статика комнаты: пол, стены, двери ────────────────────
|
||||
|
||||
private buildRoom(room: Room): void {
|
||||
this.clearGroup(this.roomGroup);
|
||||
this.renderedRoom = room;
|
||||
this.renderedCleared = room.cleared;
|
||||
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const t = room.tiles[r][c];
|
||||
let mat: THREE.MeshBasicMaterial;
|
||||
let z = Z.floor;
|
||||
if (t === T_WALL) { mat = this.tileMats.wall; z = Z.wall; }
|
||||
else if (t === T_DOOR) { mat = this.tileMats.door; z = Z.door; }
|
||||
else { mat = (r + c) % 2 === 0 ? this.tileMats.floorA : this.tileMats.floorB; }
|
||||
// Пол — одна плоскость на весь периметр, ПЛАШМЯ (flatMesh кладёт её
|
||||
// горизонтально; без этого пол стоял бы вертикально и перспектива «выворачивалась»).
|
||||
const floor = this.flatMesh(this.floorMat);
|
||||
floor.scale.set(RW, RH, 1);
|
||||
floor.position.set(OX + RW / 2, 0, OY + RH / 2);
|
||||
this.roomGroup.add(floor);
|
||||
|
||||
const mesh = new THREE.Mesh(this.unitPlane, mat);
|
||||
mesh.scale.set(TILE, TILE, 1);
|
||||
mesh.position.set(OX + c * TILE + TILE / 2, OY + r * TILE + TILE / 2, z);
|
||||
this.roomGroup.add(mesh);
|
||||
}
|
||||
// Стены по сторонам с проёмами под двери.
|
||||
const xGap: [number, number] | null =
|
||||
room.doors.up || room.doors.down ? [OX + 6 * TILE, OX + 6 * TILE + GAP] : null;
|
||||
const zGap: [number, number] | null =
|
||||
room.doors.left || room.doors.right ? [OY + 4 * TILE, OY + 4 * TILE + GAP] : null;
|
||||
this.addWall('x', OY, OX, OX + RW, room.doors.up ? xGap : null); // север
|
||||
this.addWall('x', OY + RH, OX, OX + RW, room.doors.down ? xGap : null); // юг
|
||||
this.addWall('z', OX, OY, OY + RH, room.doors.left ? zGap : null); // запад
|
||||
this.addWall('z', OX + RW, OY, OY + RH, room.doors.right ? zGap : null); // восток
|
||||
|
||||
// Двери (всегда видны: закрыты в бою, открыты после зачистки).
|
||||
const open = room.cleared;
|
||||
if (room.doors.up) this.addDoor('x', OX + 7.5 * TILE, OY, open);
|
||||
if (room.doors.down) this.addDoor('x', OX + 7.5 * TILE, OY + RH, open);
|
||||
if (room.doors.left) this.addDoor('z', OX, OY + 5.5 * TILE, open);
|
||||
if (room.doors.right) this.addDoor('z', OX + RW, OY + 5.5 * TILE, open);
|
||||
}
|
||||
|
||||
/** Вертикальная стена вдоль оси axis на координате edge от a до b, с проёмом gap. */
|
||||
private addWall(axis: 'x' | 'z', edge: number, a: number, b: number, gap: [number, number] | null): void {
|
||||
const segs: Array<[number, number]> = gap
|
||||
? [[a, gap[0]], [gap[1], b]].filter(([s, e]) => e - s > 1) as Array<[number, number]>
|
||||
: [[a, b]];
|
||||
for (const [s, e] of segs) {
|
||||
const mesh = new THREE.Mesh(this.vGeo, this.wallMat);
|
||||
const mid = (s + e) / 2;
|
||||
mesh.scale.set(e - s, WALL_H, 1);
|
||||
if (axis === 'x') mesh.position.set(mid, WALL_H / 2, edge);
|
||||
else { mesh.rotation.y = Math.PI / 2; mesh.position.set(edge, WALL_H / 2, mid); }
|
||||
this.roomGroup.add(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Динамические сущности ─────────────────────────────────
|
||||
/** Дверь в проёме: вертикальный спрайт (закрытая/открытая). */
|
||||
private addDoor(axis: 'x' | 'z', x: number, edgeOrZ: number, open: boolean): void {
|
||||
const mat = new THREE.MeshBasicMaterial({ map: this.assets.door(open), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide });
|
||||
const mesh = new THREE.Mesh(this.vGeo, mat);
|
||||
mesh.scale.set(GAP, WALL_H, 1);
|
||||
if (axis === 'x') mesh.position.set(x, WALL_H / 2, edgeOrZ);
|
||||
else { mesh.rotation.y = Math.PI / 2; mesh.position.set(x, WALL_H / 2, edgeOrZ); }
|
||||
mesh.userData.disposable = true; // материал двери персональный — освобождаем
|
||||
this.roomGroup.add(mesh);
|
||||
}
|
||||
|
||||
// ── Динамика ──────────────────────────────────────────────
|
||||
|
||||
private syncPlayer(game: Game, alpha: number): void {
|
||||
const p = game.player;
|
||||
const x = lerp(p.prevX, p.x, alpha);
|
||||
const y = lerp(p.prevY, p.y, alpha);
|
||||
this.playerMesh.position.set(x, y, Z.entity);
|
||||
this.playerMesh.scale.set(p.w, p.h, 1);
|
||||
const z = lerp(p.prevY, p.y, alpha);
|
||||
|
||||
const base = p.mode === MODE_RANGED ? this.theme.playerRanged : this.theme.playerMelee;
|
||||
const flashing = p.invTimer > 0 && p.invTimer % 6 < 3;
|
||||
(this.playerMesh.material as THREE.MeshBasicMaterial).color.setHex(flashing ? this.theme.flash : base);
|
||||
this.playerMesh.material = p.mode === MODE_RANGED ? this.playerMat.ranged : this.playerMat.melee;
|
||||
const w = p.w * SPRITE_SCALE;
|
||||
const h = w * SPRITE_ASPECT;
|
||||
this.playerMesh.scale.set(w, h, 1);
|
||||
this.playerMesh.position.set(x, h / 2, z);
|
||||
this.placeShadow(this.playerShadow, x, z, p.w);
|
||||
|
||||
// I-frames: мигаем спрайтом (классические кадры неуязвимости).
|
||||
this.playerMesh.visible = !(p.invTimer > 0 && p.invTimer % 6 < 3);
|
||||
|
||||
// Вспышка из дула при выстреле (atkCD «подскочил» вверх).
|
||||
if (p.mode === MODE_RANGED && p.atkCD > this.lastAtkCD) {
|
||||
const [dx, dz] = DIR[p.facing];
|
||||
this.spawnEffect(this.assets.muzzle(), this.theme.flash,
|
||||
x + dx * (p.w * 0.7), h * 0.55, z + dz * (p.w * 0.7), 16, 6, { vy: 0, grow: 1.04 });
|
||||
}
|
||||
this.lastAtkCD = p.atkCD;
|
||||
}
|
||||
|
||||
private syncEnemies(room: Room, alpha: number): void {
|
||||
@@ -149,25 +225,44 @@ export class ThreeRenderer implements Renderer {
|
||||
if (!e.alive) continue;
|
||||
live.add(e);
|
||||
|
||||
let mesh = this.enemyMeshes.get(e);
|
||||
if (!mesh) {
|
||||
const geo = e.type === 'normal' ? this.unitPlane : this.unitCircle;
|
||||
mesh = new THREE.Mesh(geo, flatMat());
|
||||
mesh.position.z = Z.entity;
|
||||
this.scene.add(mesh);
|
||||
this.enemyMeshes.set(e, mesh);
|
||||
let v = this.enemyVisuals.get(e);
|
||||
if (!v) {
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
map: this.assets.sprite(this.enemyMatKey[e.type]), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide,
|
||||
});
|
||||
const sprite = new THREE.Mesh(this.vGeo, mat);
|
||||
const shadow = this.flatMesh(this.shadowMat);
|
||||
this.scene.add(sprite, shadow);
|
||||
v = { sprite, shadow, lastHit: 0 };
|
||||
this.enemyVisuals.set(e, v);
|
||||
}
|
||||
|
||||
const x = lerp(e.prevX, e.x, alpha);
|
||||
const y = lerp(e.prevY, e.y, alpha);
|
||||
mesh.position.set(x, y, Z.entity);
|
||||
mesh.scale.set(e.w, e.h, 1);
|
||||
const z = lerp(e.prevY, e.y, alpha);
|
||||
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);
|
||||
|
||||
const base = e.type === 'fast' ? this.theme.enemyFast : e.type === 'boss' ? this.theme.enemyBoss : this.theme.enemyNormal;
|
||||
const flashing = e.hitTimer > 0 && e.hitTimer % 4 < 2;
|
||||
(mesh.material as THREE.MeshBasicMaterial).color.setHex(flashing ? this.theme.flash : base);
|
||||
// Искра в момент попадания (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 });
|
||||
}
|
||||
v.lastHit = e.hitTimer;
|
||||
}
|
||||
|
||||
// Уборка: исчезнувшие враги. Если враг мёртв — «пуф» на месте гибели.
|
||||
for (const [e, v] of this.enemyVisuals) {
|
||||
if (live.has(e)) continue;
|
||||
if (!e.alive) {
|
||||
this.spawnEffect(this.assets.puff(), 0xffffff, e.x, e.w * 0.6, e.y, e.w * 1.2, 14, { vy: 1.1, grow: 1.07 });
|
||||
}
|
||||
this.scene.remove(v.sprite, v.shadow);
|
||||
(v.sprite.material as THREE.Material).dispose();
|
||||
this.enemyVisuals.delete(e);
|
||||
}
|
||||
this.sweep(this.enemyMeshes, live);
|
||||
}
|
||||
|
||||
private syncTears(room: Room, alpha: number): void {
|
||||
@@ -175,56 +270,106 @@ export class ThreeRenderer implements Renderer {
|
||||
for (const t of room.tears) {
|
||||
if (!t.alive) continue;
|
||||
live.add(t);
|
||||
|
||||
let mesh = this.tearMeshes.get(t);
|
||||
if (!mesh) {
|
||||
mesh = new THREE.Mesh(this.unitCircle, flatMat({ color: this.theme.tear }));
|
||||
mesh.position.z = Z.tear;
|
||||
mesh.scale.set(PROJECTILE.radius * 2, PROJECTILE.radius * 2, 1);
|
||||
mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
|
||||
map: this.assets.tear(), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
}));
|
||||
const s = 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), lerp(t.prevY, t.y, alpha), Z.tear);
|
||||
mesh.position.set(lerp(t.prevX, t.x, alpha), TEAR_Y, lerp(t.prevY, t.y, alpha));
|
||||
}
|
||||
for (const [t, mesh] of this.tearMeshes) {
|
||||
if (live.has(t)) continue;
|
||||
this.scene.remove(mesh);
|
||||
(mesh.material as THREE.Material).dispose();
|
||||
this.tearMeshes.delete(t);
|
||||
}
|
||||
this.sweep(this.tearMeshes, live);
|
||||
}
|
||||
|
||||
private syncSwing(game: Game): void {
|
||||
const s = game.meleeSwing;
|
||||
if (!s || !s.alive) { this.swingMesh.visible = false; return; }
|
||||
this.swingMesh.visible = true;
|
||||
this.swingMesh.position.set(s.box.x + s.box.w / 2, s.box.y + s.box.h / 2, Z.swing);
|
||||
this.swingMesh.scale.set(s.box.w, s.box.h, 1);
|
||||
(this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.45 * (s.life / 10);
|
||||
this.swingMesh.position.set(s.box.x + s.box.w / 2, 2, s.box.y + s.box.h / 2);
|
||||
this.swingMesh.scale.set(s.box.w * 1.4, s.box.h * 1.4, 1);
|
||||
(this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / MELEE.life);
|
||||
}
|
||||
|
||||
// ── Утилиты управления ресурсами ──────────────────────────
|
||||
// ── Эффекты (частицы-биллборды) ───────────────────────────
|
||||
|
||||
/** Удаляет меши, чьих сущностей больше нет, освобождая их материалы. */
|
||||
private sweep<K>(map: Map<K, THREE.Mesh>, live: Set<K>): void {
|
||||
for (const [key, mesh] of map) {
|
||||
if (live.has(key)) continue;
|
||||
this.scene.remove(mesh);
|
||||
(mesh.material as THREE.Material).dispose();
|
||||
map.delete(key);
|
||||
private spawnEffect(
|
||||
tex: THREE.Texture, color: number, x: number, y: number, z: number,
|
||||
size: number, life: number, opts: { vy: number; grow: number },
|
||||
): void {
|
||||
const mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
|
||||
map: tex, color: new THREE.Color(color), transparent: true,
|
||||
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
}));
|
||||
mesh.scale.set(size, size, 1);
|
||||
mesh.position.set(x, y, z);
|
||||
this.scene.add(mesh);
|
||||
this.effects.push({ mesh, life, max: life, vy: opts.vy, grow: opts.grow });
|
||||
}
|
||||
|
||||
private updateEffects(): void {
|
||||
for (let i = this.effects.length - 1; i >= 0; i--) {
|
||||
const fx = this.effects[i];
|
||||
fx.life--;
|
||||
const mat = fx.mesh.material as THREE.MeshBasicMaterial;
|
||||
if (fx.life <= 0) {
|
||||
this.scene.remove(fx.mesh);
|
||||
mat.dispose();
|
||||
this.effects.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
mat.opacity = fx.life / fx.max;
|
||||
fx.mesh.position.y += fx.vy;
|
||||
fx.mesh.scale.multiplyScalar(fx.grow);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Хелперы ───────────────────────────────────────────────
|
||||
|
||||
/** Плоский (лежащий на полу) меш из общей геометрии. */
|
||||
private flatMesh(mat: THREE.Material): THREE.Mesh {
|
||||
const m = new THREE.Mesh(this.flatGeo, mat);
|
||||
m.rotation.x = -Math.PI / 2; // положить плашмя, нормаль вверх
|
||||
return m;
|
||||
}
|
||||
|
||||
private placeShadow(shadow: THREE.Mesh, x: number, z: number, footprint: number): void {
|
||||
shadow.position.set(x, SHADOW_Y, z);
|
||||
shadow.scale.set(footprint * 1.4, footprint * 0.9, 1);
|
||||
}
|
||||
|
||||
private clearGroup(group: THREE.Group): void {
|
||||
// Материалы и геометрия тайлов общие (живут весь срок рендера),
|
||||
// поэтому здесь только убираем меши из сцены — без dispose.
|
||||
for (const child of group.children) {
|
||||
// Общие материалы (пол/стены) не трогаем; персональные (двери) — освобождаем.
|
||||
if ((child as THREE.Mesh).userData?.disposable) {
|
||||
((child as THREE.Mesh).material as THREE.Material).dispose();
|
||||
}
|
||||
}
|
||||
group.clear();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearGroup(this.roomGroup);
|
||||
this.sweep(this.enemyMeshes, new Set());
|
||||
this.sweep(this.tearMeshes, new Set());
|
||||
(this.playerMesh.material as THREE.Material).dispose();
|
||||
for (const v of this.enemyVisuals.values()) (v.sprite.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();
|
||||
(this.swingMesh.material as THREE.Material).dispose();
|
||||
this.unitPlane.dispose();
|
||||
this.unitCircle.dispose();
|
||||
for (const m of Object.values(this.tileMats)) m.dispose();
|
||||
this.floorMat.dispose();
|
||||
this.wallMat.dispose();
|
||||
this.shadowMat.dispose();
|
||||
this.playerMat.ranged.dispose();
|
||||
this.playerMat.melee.dispose();
|
||||
this.vGeo.dispose();
|
||||
this.flatGeo.dispose();
|
||||
this.assets.dispose();
|
||||
this.renderer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* assets.ts — СТАНДАРТНЫЕ АССЕТЫ, нарисованные процедурно на canvas и
|
||||
* превращённые в текстуры three.js. Никаких внешних файлов: спрайты «зашиты»
|
||||
* в код, поэтому проект самодостаточен и легко версионируется.
|
||||
*
|
||||
* Как заменить на свои картинки: вместо рисования на canvas загрузи PNG через
|
||||
* `new THREE.TextureLoader().load('путь.png')` и верни его из соответствующего
|
||||
* геттера. Остальной рендер не изменится — он просто берёт текстуру по имени.
|
||||
*
|
||||
* Текстуры кэшируются (строятся один раз) и освобождаются в dispose().
|
||||
*/
|
||||
|
||||
export type SpriteKey =
|
||||
| 'player-ranged' | 'player-melee'
|
||||
| 'enemy-normal' | 'enemy-fast' | 'enemy-boss';
|
||||
|
||||
function canvas(w: number, h: number): { cv: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = w;
|
||||
cv.height = h;
|
||||
return { cv, ctx: cv.getContext('2d')! };
|
||||
}
|
||||
|
||||
function texture(cv: HTMLCanvasElement, pixelated = true): THREE.CanvasTexture {
|
||||
const t = new THREE.CanvasTexture(cv);
|
||||
t.colorSpace = THREE.SRGBColorSpace;
|
||||
if (pixelated) {
|
||||
t.magFilter = THREE.NearestFilter;
|
||||
t.minFilter = THREE.NearestFilter;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Скруглённый прямоугольник (хелпер рисования). */
|
||||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
/** Большеголовый персонаж в духе Isaac (вертикальный спрайт 48×64). */
|
||||
function drawCharacter(
|
||||
opts: { head: string; body: string; outline: string; eye?: string; horns?: boolean; small?: boolean },
|
||||
): HTMLCanvasElement {
|
||||
const { cv, ctx } = canvas(48, 64);
|
||||
const cx = 24;
|
||||
const scale = opts.small ? 0.85 : 1;
|
||||
const headR = 15 * scale;
|
||||
const headY = 24;
|
||||
|
||||
// Тело (туника) снизу.
|
||||
ctx.fillStyle = opts.body;
|
||||
roundRect(ctx, cx - 13 * scale, headY + 6, 26 * scale, 26 * scale, 6);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = opts.outline;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Ножки.
|
||||
ctx.fillStyle = opts.outline;
|
||||
ctx.fillRect(cx - 9 * scale, headY + 28, 6, 8);
|
||||
ctx.fillRect(cx + 3 * scale, headY + 28, 6, 8);
|
||||
|
||||
// Рога (для босса) — за головой.
|
||||
if (opts.horns) {
|
||||
ctx.fillStyle = opts.outline;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - 13, headY - 9); ctx.lineTo(cx - 18, headY - 22); ctx.lineTo(cx - 6, headY - 11); ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx + 13, headY - 9); ctx.lineTo(cx + 18, headY - 22); ctx.lineTo(cx + 6, headY - 11); ctx.fill();
|
||||
}
|
||||
|
||||
// Голова.
|
||||
ctx.fillStyle = opts.head;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, headY, headR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = opts.outline;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Глаза.
|
||||
ctx.fillStyle = opts.eye ?? '#1a1a1a';
|
||||
ctx.beginPath(); ctx.arc(cx - 6, headY - 1, 3, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.beginPath(); ctx.arc(cx + 6, headY - 1, 3, 0, Math.PI * 2); ctx.fill();
|
||||
|
||||
return cv;
|
||||
}
|
||||
|
||||
/** Плиточная текстура пола (тёмный камень, бесшовная). */
|
||||
function drawFloor(): HTMLCanvasElement {
|
||||
const { cv, ctx } = canvas(64, 64);
|
||||
ctx.fillStyle = '#6b6657';
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
ctx.fillStyle = '#5f5a4c';
|
||||
ctx.fillRect(0, 0, 32, 32);
|
||||
ctx.fillRect(32, 32, 32, 32);
|
||||
// лёгкие «трещинки»/крапинки
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.13)';
|
||||
for (const [x, y] of [[8, 12], [40, 6], [54, 40], [18, 48], [30, 28]]) ctx.fillRect(x, y, 3, 3);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.18)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, 63, 63);
|
||||
return cv;
|
||||
}
|
||||
|
||||
/** Кирпичная текстура стены. */
|
||||
function drawWall(): HTMLCanvasElement {
|
||||
const { cv, ctx } = canvas(64, 64);
|
||||
ctx.fillStyle = '#474757';
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
ctx.fillStyle = '#55556a';
|
||||
const bh = 16;
|
||||
for (let row = 0; row * bh < 64; row++) {
|
||||
const off = row % 2 === 0 ? 0 : -16;
|
||||
for (let x = off; x < 64; x += 32) {
|
||||
ctx.fillRect(x + 1, row * bh + 1, 30, bh - 2);
|
||||
}
|
||||
}
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
|
||||
ctx.strokeRect(0.5, 0.5, 63, 63);
|
||||
return cv;
|
||||
}
|
||||
|
||||
/** Снаряд-«слеза» (голубая капля со свечением, прозрачный фон). */
|
||||
function drawTear(): HTMLCanvasElement {
|
||||
const { cv, ctx } = canvas(32, 32);
|
||||
const g = ctx.createRadialGradient(16, 16, 1, 16, 16, 15);
|
||||
g.addColorStop(0, '#dff0ff');
|
||||
g.addColorStop(0.4, '#6699cc');
|
||||
g.addColorStop(1, 'rgba(40,80,140,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath(); ctx.arc(16, 16, 15, 0, Math.PI * 2); ctx.fill();
|
||||
return cv;
|
||||
}
|
||||
|
||||
/** Радиальная мягкая «вспышка» (для дула, попаданий, частиц). */
|
||||
function drawGlow(inner: string, outer: string): HTMLCanvasElement {
|
||||
const { cv, ctx } = canvas(64, 64);
|
||||
const g = ctx.createRadialGradient(32, 32, 1, 32, 32, 31);
|
||||
g.addColorStop(0, inner);
|
||||
g.addColorStop(0.5, outer);
|
||||
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
return cv;
|
||||
}
|
||||
|
||||
/** Дверь: закрытая (засов) или открытый тёмный проём. Прозрачный фон. */
|
||||
function drawDoor(open: boolean): HTMLCanvasElement {
|
||||
const { cv, ctx } = canvas(64, 64);
|
||||
// Рама-арка.
|
||||
ctx.fillStyle = '#1c1a14';
|
||||
ctx.fillRect(4, 4, 56, 60);
|
||||
ctx.fillStyle = '#070707'; // тёмный проём
|
||||
ctx.fillRect(12, 12, 40, 52);
|
||||
|
||||
if (!open) {
|
||||
// Створки + засов (закрыто).
|
||||
ctx.fillStyle = '#3a2e14';
|
||||
ctx.fillRect(12, 12, 40, 52);
|
||||
ctx.strokeStyle = '#241a08';
|
||||
ctx.lineWidth = 2;
|
||||
for (let x = 18; x < 52; x += 10) { ctx.beginPath(); ctx.moveTo(x, 12); ctx.lineTo(x, 64); ctx.stroke(); }
|
||||
ctx.fillStyle = '#9a8a4a'; // засов
|
||||
ctx.fillRect(10, 32, 44, 7);
|
||||
ctx.fillStyle = '#cdbf78';
|
||||
ctx.fillRect(28, 30, 8, 11);
|
||||
}
|
||||
return cv;
|
||||
}
|
||||
|
||||
/** Мягкая тень-«пятно» под сущностью. */
|
||||
function drawShadow(): HTMLCanvasElement {
|
||||
const { cv, ctx } = canvas(64, 32);
|
||||
const g = ctx.createRadialGradient(32, 16, 1, 32, 16, 30);
|
||||
g.addColorStop(0, 'rgba(0,0,0,0.5)');
|
||||
g.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.save(); ctx.scale(1, 0.5); ctx.beginPath(); ctx.arc(32, 32, 30, 0, Math.PI * 2); ctx.fill(); ctx.restore();
|
||||
return cv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Кэширующий поставщик текстур. Строит лениво, отдаёт по имени, освобождает все.
|
||||
*/
|
||||
export class Assets {
|
||||
private cache = new Map<string, THREE.Texture>();
|
||||
|
||||
private get(key: string, build: () => HTMLCanvasElement, pixelated = true): THREE.Texture {
|
||||
let t = this.cache.get(key);
|
||||
if (!t) {
|
||||
t = texture(build(), pixelated);
|
||||
this.cache.set(key, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
sprite(key: SpriteKey): THREE.Texture {
|
||||
return this.get(key, () => {
|
||||
switch (key) {
|
||||
case 'player-ranged': return drawCharacter({ head: '#e8d2b0', body: '#2a6a9a', outline: '#16324a', eye: '#123' });
|
||||
case 'player-melee': return drawCharacter({ head: '#e8d2b0', body: '#9a3a2a', outline: '#4a160e', eye: '#123' });
|
||||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
floor(): THREE.Texture { return this.get('floor', drawFloor); }
|
||||
wall(): THREE.Texture { return this.get('wall', drawWall); }
|
||||
tear(): THREE.Texture { return this.get('tear', drawTear, false); }
|
||||
shadow(): THREE.Texture { return this.get('shadow', drawShadow, false); }
|
||||
door(open: boolean): THREE.Texture { return this.get(open ? 'door-open' : 'door-closed', () => drawDoor(open)); }
|
||||
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); }
|
||||
|
||||
dispose(): void {
|
||||
for (const t of this.cache.values()) t.dispose();
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
+15
-38
@@ -1,48 +1,25 @@
|
||||
/**
|
||||
* theme.ts — ВНЕШНИЙ ВИД мира (задел под кастомные ассеты).
|
||||
* theme.ts — цвета-ТИНТЫ, которыми пользуется рендер напрямую.
|
||||
*
|
||||
* Сейчас «ассеты» — это просто цвета примитивов (квадраты/круги). Но рендер
|
||||
* берёт их отсюда, а не из хардкода, поэтому вид легко подменить, не трогая
|
||||
* логику: можно завести несколько тем или, в перспективе, расширить Theme
|
||||
* полями со спрайтами/текстурами (см. комментарий ниже) и научить
|
||||
* ThreeRenderer вешать их на материалы.
|
||||
* Основной внешний вид мира (пол, стены, двери, персонажи, снаряды) теперь живёт
|
||||
* в текстурах `render/assets.ts` (процедурные спрайты). Сюда вынесено лишь то, что
|
||||
* рендер задаёт цветом материала, а не текстурой:
|
||||
* • bg — цвет фона (clear color) сцены;
|
||||
* • swing — тинт спрайта взмаха ближнего боя;
|
||||
* • flash — тинт вспышек/искр (дуло, попадание).
|
||||
*
|
||||
* Хочешь полностью сменить стиль — меняй ассеты (см. `docs/HOWTO.md` и
|
||||
* `docs/ASSET_BRIEF.md`); хочешь подкрутить фон/эффекты — здесь.
|
||||
*/
|
||||
export interface Theme {
|
||||
/** Цвета (0xRRGGBB) элементов мира. */
|
||||
bg: number;
|
||||
floorA: number;
|
||||
floorB: number;
|
||||
wall: number;
|
||||
door: number;
|
||||
playerRanged: number;
|
||||
playerMelee: number;
|
||||
enemyNormal: number;
|
||||
enemyFast: number;
|
||||
enemyBoss: number;
|
||||
tear: number;
|
||||
swing: number;
|
||||
flash: number; // цвет «вспышки» при попадании/неуязвимости
|
||||
|
||||
// ── Задел на будущее (пока не используется) ───────────────
|
||||
// Чтобы перейти со сплошных цветов на картинки, добавь сюда, например:
|
||||
// textures?: { floor?: string; wall?: string; player?: string; ... }
|
||||
// (URL/путь к изображению), загрузи их через THREE.TextureLoader в
|
||||
// ThreeRenderer и положи в material.map вместо/вместе с color.
|
||||
bg: number; // фон сцены (0xRRGGBB)
|
||||
swing: number; // тинт взмаха ближнего боя
|
||||
flash: number; // тинт вспышек/искр
|
||||
}
|
||||
|
||||
/** Тема по умолчанию — текущая «тёмное подземелье». */
|
||||
/** Тема по умолчанию — «тёмное подземелье». */
|
||||
export const DEFAULT_THEME: Theme = {
|
||||
bg: 0x0a0a0f,
|
||||
floorA: 0x2e2e24,
|
||||
floorB: 0x353528,
|
||||
wall: 0x242436,
|
||||
door: 0x3a2e14,
|
||||
playerRanged: 0x2a6a9a,
|
||||
playerMelee: 0x9a3a2a,
|
||||
enemyNormal: 0x5a4a2e,
|
||||
enemyFast: 0x992222,
|
||||
enemyBoss: 0x5a0a0a,
|
||||
tear: 0x6699cc,
|
||||
bg: 0x1c1c28,
|
||||
swing: 0xcc8844,
|
||||
flash: 0xdddddd,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user