Author SHA1 Message Date
mayatnikovandClaude Opus 4.8 7dabfd4fff docs: синхронизировать описание ассетов с реальностью (PNG из src/assets + фолбэк)
Ревью отметило устаревшие тексты после перехода на загрузку PNG:
- assets.ts: заголовок описывал «процедурно, без внешних файлов» — теперь PNG из
  src/assets/<ключ>.png с процедурным фолбэком.
- HOWTO §8: убран совет «заменить тело drawX() на TextureLoader» (уже сделано);
  актуальный путь — положить PNG с нужным именем в src/assets/.
- README/ARCHITECTURE/ASSET_BRIEF: статус «подключено», корректный механизм (рантайм-
  загрузка по пути, не импорт-бандлинг).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:15:36 +03:00
mayatnikovandClaude Opus 4.8 35cb8de33a feat(art): подключены дизайнерские ассеты + сердечки/иконки/лого в UI
- Добавлены 21 PNG из Claude Design в src/assets/ (спрайты игрока и врагов,
  пол, стены, двери, снаряд, эффекты, тень, сердечки, иконки оружия, лого, фон меню).
- HudOverlay: здоровье сердечками (2 HP = сердце) + иконка режима оружия;
  фолбэк на полосу/без иконки, если картинок нет.
- index.html: лого и фон-подземелье в стартовом меню (с тёмным затемнением).
- Мир/персонажи/двери теперь рисуются дизайнерскими спрайтами (через assets.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:08:40 +03:00
mayatnikovandClaude Opus 4.8 e2cb1ff855 feat(assets): загрузка PNG из src/assets с процедурным фолбэком
- assets.ts: текстуры грузятся из src/assets/<key>.png; если файла нет —
  откат на процедурный рисунок (игра не ломается, сборка не зависит от наличия PNG).
- dev.ts: отдаёт /assets/* прямо из src/assets/ (положил PNG → сразу подхватился).
- build.ts: копирует src/assets → dist/assets.
- Имена ключей = именам файлов из docs/ASSET_BRIEF.md (player-ranged.png и т.д.).
- Добавлен src/assets/tear.png как рабочий пример из дизайна.

Чтобы подключить полный арт: положить 21 PNG из проекта Claude Design в src/assets/.
Бинарные ассеты переносятся файлами (не через модель — это триггерит usage-policy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:00:35 +03:00
mayatnikovandClaude Opus 4.8 3bb46e2f6b 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>
2026-06-18 15:16:57 +03:00
Manul 5a8cf0e500 Merge pull request #1 from halofourteen/refactor/game-foundation
Рефакторинг: рабочая база на three.js + меню/правила уровней + доки
2026-06-18 14:22:17 +03:00
35 changed files with 799 additions and 224 deletions
+7 -5
View File
@@ -7,7 +7,7 @@
## Что это
Top-down рогалик (в духе Binding of Isaac). Логика — чистый TypeScript
(`src/core/`), рендер — three.js с ортокамерой (`src/render/`), сборка/тесты — Bun.
(`src/core/`), рендер — three.js в псевдо-3D (наклонная `PerspectiveCamera`, `src/render/`), сборка/тесты — Bun.
Обзор — [`README.md`](./README.md), детали — [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md),
рецепты доработки — [`docs/HOWTO.md`](./docs/HOWTO.md).
@@ -64,8 +64,9 @@ bun run check # typecheck + test
| форму комнаты/тайлы | `src/core/world/tiles.ts`, `Room.ts` |
| коллизии | `src/core/systems/collision.ts` |
| расстановку врагов | `src/core/systems/spawner.ts` |
| как рисуется мир | `src/render/ThreeRenderer.ts` |
| цвета/внешний вид/ассеты мира | `src/render/theme.ts` |
| как рисуется мир (псевдо-3D) | `src/render/ThreeRenderer.ts` |
| спрайты/текстуры (графика) | `src/render/assets.ts` (бриф на художку — `docs/ASSET_BRIEF.md`) |
| цвета/тинты/фон | `src/render/theme.ts` |
| HUD/миникарту | `src/render/HudOverlay.ts` |
| стартовое меню | `src/ui/StartMenu.ts` |
| раскладку клавиш | `src/input/KeyboardController.ts` |
@@ -80,8 +81,9 @@ bun run check # typecheck + test
Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте.
- **`OPP` направлений.** Противоположное к `up` — это `down`, к `left``right`.
Любая другая раскладка ломает встречные двери и связность карты.
- **Чёрный экран при работающем рендере.** Ортокамера инвертирует Y → нужен
`DoubleSide` на материалах, иначе грани отсекаются.
- **Пол «выворачивает» перспективу.** Пол в псевдо-3D надо класть ПЛАШМЯ
(`flatMesh`, поворот −90° вокруг X). Без поворота он встаёт вертикально и вид
ломается. Спрайты/стены — `DoubleSide` (камера переворачивает Y, иначе грани отсекаются).
- **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`),
иначе они показываются в размер HiDPI-буфера.
- **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) —
+14 -7
View File
@@ -1,12 +1,15 @@
# Dungeon Crawl — рогалик в духе The Binding of Isaac
# Биндим Фигняшку — рогалик в духе The Binding of Isaac
Top-down рогалик: процедурный данжен из комнат, два режима боя (дальний/ближний),
враги, босс. Логика на чистом TypeScript, рендер — на **three.js** (ортографическая
камера, картинка плоская 2D-сверху). Сборка — **Bun**.
враги, босс. Логика на чистом TypeScript, рендер — на **three.js** в **псевдо-3D**:
наклонная камера, пол лежит плашмя, персонажи — вертикальные спрайты-биллборды
(как в Isaac). Сборка — **Bun**.
Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
Внешний вид мира вынесен в «тему» — задел под кастомные ассеты (спрайты/текстуры).
Двери видны всегда (закрыты в бою, открыты после зачистки). Графика — PNG-ассеты в
`src/assets/` (грузятся в `render/assets.ts`; при отсутствии файла — процедурный
фолбэк). Бриф на художку — `docs/ASSET_BRIEF.md`.
> Это рабочая **база для развития**, а не готовая игра. Архитектура специально
> сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам.
@@ -44,6 +47,9 @@ bun run check # и то, и другое разом
| R | заново (на экране Game Over / Victory) |
| Esc | вернуться в меню выбора уровня |
Клавиши привязаны к **физическим** кнопкам (`event.code`), поэтому WASD/Q/R работают
в любой раскладке (в т.ч. русской), независимо от языка ввода.
Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на
дверь, нажимая в её сторону.
@@ -66,7 +72,7 @@ bun run check # и то, и другое разом
## Стек и устройство
- **Bun** — рантайм, бандлер и тест-раннер.
- **three.js** — WebGL-рендер мира через ортокамеру.
- **three.js** — WebGL-рендер мира в псевдо-3D (наклонная `PerspectiveCamera`, биллборд-спрайты).
- **TypeScript (strict)** — весь код.
- Архитектура: **логика игры полностью отделена от рендера**. Ядро (`src/core/`)
не знает ни про DOM, ни про three.js, поэтому его легко тестировать и при
@@ -94,8 +100,9 @@ src/
│ └── KeyboardController.ts клавиатура → InputState
├── render/ ── РЕНДЕР (читает состояние, рисует) ──
│ ├── Renderer.ts интерфейс рендера
│ ├── ThreeRenderer.ts мир на three.js (ортокамера)
│ ├── theme.ts ВНЕШНИЙ ВИД (цвета; задел под спрайты/текстуры)
│ ├── ThreeRenderer.ts мир на three.js (псевдо-3D: наклон + биллборды)
│ ├── assets.ts ⭐ АССЕТЫ (процедурные спрайты/текстуры → текстуры three.js)
│ ├── theme.ts цвета/тинты мира (фон, эффекты)
│ └── HudOverlay.ts HUD и миникарта на 2D-канвасе поверх
├── ui/
│ └── StartMenu.ts стартовое меню (DOM) с выбором уровня
+10
View File
@@ -3,6 +3,7 @@
* плюс копия index.html. Открывай dist/index.html.
*/
import { build } from 'bun';
import { cp, mkdir } from 'node:fs/promises';
const result = await build({
entrypoints: ['./src/main.ts'],
@@ -19,4 +20,13 @@ if (!result.success) {
}
await Bun.write('./dist/index.html', await Bun.file('./index.html').text());
// Копируем картинки в dist/assets (если папка есть).
try {
await mkdir('./dist/assets', { recursive: true });
await cp('./src/assets', './dist/assets', { recursive: true });
} catch {
// src/assets ещё нет — не страшно, рендер откатится на процедурную графику.
}
console.log('Сборка готова → dist/ (открой dist/index.html)');
+6
View File
@@ -22,6 +22,12 @@ const server = Bun.serve({
if (url.pathname === '/' || url.pathname === '/index.html') {
return new Response(Bun.file('./index.html'));
}
// Картинки отдаём прямо из src/assets/ — положил PNG → сразу подхватился (без пересборки).
if (url.pathname.startsWith('/assets/')) {
const asset = Bun.file('./src' + url.pathname);
if (await asset.exists()) return new Response(asset);
return new Response('Not found', { status: 404 });
}
const file = Bun.file('./dist' + url.pathname);
if (await file.exists()) return new Response(file);
return new Response('Not found', { status: 404 });
+41 -15
View File
@@ -105,26 +105,52 @@
- **`#hud`** — 2D-канвас поверх, `HudOverlay` рисует HP, индикатор режима,
счётчик врагов, подпись комнаты, миникарту и оверлеи Game Over/Victory.
### ThreeRenderer — ортокамера и «плоское 2D»
### ThreeRenderer — псевдо-3D (наклонный вид, как в Isaac)
- `OrthographicCamera(0, CW, 0, CH, …)` отображает мировые координаты **один в
один** в пиксельные (x вправо, y вниз). Поэтому вся математика ядра валидна без
пересчётов. Слои по `z` (пол < стены < сущности < снаряды).
- Камера переворачивает ось Y → инвертируется порядок вершин → при обычном
отсечении задних граней плоскости были бы невидимы. Поэтому все материалы —
**`DoubleSide`** (правильный выбор для плоских спрайтов).
Логика остаётся 2D-сверху, но РЕНДЕР — псевдо-3D:
> **Исторический баг №3.** Именно из-за инверсии Y и отсечения граней мир рисовался
> «в пустоту» (чёрный экран при работающих draw-call). Лечится `DoubleSide`.
- **Карта координат:** игровая точка `(x, y)` кладётся на пол в 3D как `(x, 0, y)`
— пол это плоскость `Y=0`, вверх это `+Y`, «низ» игры (`y`) идёт в глубину (`Z`).
Поэтому вся математика и **коллизии ядра без изменений** — псевдо-3D чисто визуальный.
- **Камера** — `PerspectiveCamera`, наклонная и фиксированная на комнату (приподнята и
отодвинута на «юг», смотрит вниз-вперёд ≈50°). Кадрирует комнату целиком.
- **Пол** — одна горизонтальная плоскость с бесшовной текстурой (повтор по сетке).
⚠️ Плоскость кладём ПЛАШМЯ через `flatMesh()` (поворот −90° вокруг X). Если забыть
поворот — пол встанет вертикально и перспектива «вывернется» (был такой баг).
- **Стены** — вертикальные плоскости по периметру с проёмами под двери.
- **Персонажи/враги/двери/снаряды** — ВЕРТИКАЛЬНЫЕ спрайты-биллборды (плоскости в XY,
нормаль +Z), стоящие на полу: центр на `y = высота/2`, низ на полу. Текстуры — из
`assets.ts` (см. ниже). Материалы — `DoubleSide` (наша камера переворачивает Y, иначе
грани отсеклись бы) + `alphaTest` для чёткого контура спрайта.
- **Двери всегда видимы:** рисуются из `room.doors` независимо от зачистки — закрытые
(засов) пока `!cleared`, открытый проём после. Группа комнаты пересобирается при смене
комнаты ИЛИ смене флага `cleared`.
- **Тени** — отдельный плоский спрайт-«пятно» под каждой сущностью.
### Ассеты (render/assets.ts)
`Assets` грузит текстуры из `src/assets/<ключ>.png` (спрайты персонажей, пол, стены,
двери, снаряд, эффекты, тень) и кэширует их; если PNG нет — рисует процедурный фолбэк
на `<canvas>`, поэтому игра не ломается без ассетов. Dev-сервер отдаёт `/assets/*` из
`src/assets/`, прод-сборка копирует их в `dist/assets/`. Подмена/добавление графики —
`docs/HOWTO.md` и `docs/ASSET_BRIEF.md`. Ключи ассетов едины в рендере, брифе и именах файлов.
### Эффекты оружия
Рендер сам распознаёт события по состоянию (ядро не трогаем):
- вспышка из дула — когда `player.atkCD` «подскочил» (выстрел);
- искра — когда у врага вырос `hitTimer` (попадание);
- облачко-«пуф» — когда мёртвый враг исчезает из комнаты.
Эффекты — короткоживущие аддитивные спрайты-биллборды, гаснут по таймеру.
### Управление ресурсами GPU (важно — иначе утечки)
- Общие геометрии-«единицы» (`unitPlane`, `unitCircle`) масштабируются под размер
сущности — не плодим геометрии.
- Тайлы комнаты пересобираются **только при смене комнаты**.
- Меши сущностей создаются/удаляются по факту появления/исчезновения
(mark-and-sweep в `sync*`), их персональные материалы корректно `dispose()`-ятся.
- Общие ресурсы освобождаются один раз в `dispose()`.
- Общие геометрии переиспользуются масштабированием — не плодим геометрии.
- Группа комнаты (пол/стены/двери) пересобирается только при смене комнаты/`cleared`;
персональные материалы дверей `dispose()`-ятся при пересборке.
- Спрайты сущностей и эффекты создаются/удаляются по факту появления/исчезновения
(mark-and-sweep), их персональные материалы корректно `dispose()`-ятся.
- Общие материалы/геометрии и `Assets` освобождаются один раз в `dispose()`.
## HiDPI
+105
View File
@@ -0,0 +1,105 @@
# Бриф на ассеты — «Биндим Фигняшку»
Документ для генерации/догенерации графики (через Claude/дизайн-ИИ или художника).
Ниже — **готовый промпт** (можно копировать целиком) и **таблица ассетов**.
> ✅ **Статус: набор уже подключён.** 21 PNG лежат в `src/assets/` и автоматически
> грузятся `src/render/assets.ts` по имени файла (`assets/<ключ>.png`); если какого-то
> файла нет — рисуется процедурный фолбэк. Этот бриф нужен, чтобы **догенерировать или
> заменить** ассеты: достаточно положить PNG с тем же именем в `src/assets/`.
---
## Готовый промпт (копировать целиком)
> Ты — художник 2D-игр. Сделай ПОЛНЫЙ набор ассетов для рогалика «Биндим Фигняшку»
> в духе The Binding of Isaac.
>
> **Игра и ракурс.** Вид сверху под наклоном (псевдо-3D): пол лежит плашмя и виден под
> углом ≈50° (камера приподнята и смотрит вниз-вперёд), а персонажи и враги — это
> ВЕРТИКАЛЬНЫЕ спрайты-биллборды, стоящие на полу и обращённые к камере (фронтальный
> вид, чуть сверху). Стены — невысокий бортик по периметру комнаты. Комната — прямоугольник
> 15×11 тайлов.
>
> **Стиль.** Мрачное подземелье, мультяшно-гротескный, читаемые силуэты, плотные тёмные
> контуры, без мелкой суеты — всё должно читаться на маленьком размере. Допустимы пиксель-арт
> ИЛИ чистый векторно-мультяшный стиль — но ЕДИНЫЙ для всего набора.
>
> **Палитра.** Пол — светлый серо-песочный камень (≈#6b6657), стены — холодный сине-серый
> кирпич (≈#47475a). Поэтому персонажи/враги должны быть НАСЫЩЕННЫМИ и контрастными, чтобы
> выделяться на светлом полу. Игрок дальнего боя — синий, ближнего — красный. Враги: «обычный» —
> тёплый терракот, «быстрый» — ярко-красный, «босс» — тёмно-багровый с рогами.
>
> **Технические требования (обязательно):**
> - PNG с прозрачным фоном (alpha), без подложки и без «приваренной» тени/пола.
> - Спрайты персонажей/врагов/дверей — ВЕРТИКАЛЬНЫЕ, соотношение 3:4 (ширина:высота),
> персонаж по центру, СТУПНИ у самого нижнего края кадра (мы «ставим» спрайт на пол по низу).
> - Текстуры пола и стен — БЕСШОВНЫЕ (tileable), повторяются по сетке.
> - Эффекты (вспышка/искра/дымок) — на прозрачном фоне, со светлым (почти белым) центром,
> чтобы их можно было тонировать цветом в движке; рассчитаны на аддитивное смешивание.
> - Никаких теней под персонажем внутри спрайта — тень рисуется отдельным ассетом.
> - Единая «толщина пикселя»/уровень детализации во всём наборе.
>
> **Что нарисовать** — см. список ниже (каждый пункт = отдельный файл с указанным именем
> и размером). Пришли файлы по этим именам (или один атлас + JSON-карта координат).
(Дальше вставь таблицу ассетов из этого файла.)
---
## Таблица ассетов
Имена файлов соответствуют ключам в `src/render/assets.ts` — присылай ровно с такими именами.
### Персонажи и враги (вертикальные спрайты 3:4, прозрачный фон, ступни у нижнего края)
| Файл | Что | Рекоменд. размер | Заметки |
|---|---|---|---|
| `player-ranged.png` | Игрок, режим «дальний» (с пистолетом) | 192×256 | синий; большая голова в духе Isaac |
| `player-melee.png` | Игрок, режим «ближний» (с ножом) | 192×256 | красный; та же база, другое оружие/цвет |
| `enemy-normal.png` | Обычный враг | 192×256 | терракот, медлительный, «толстенький» |
| `enemy-fast.png` | Быстрый враг | 160×213 | ярко-красный, мельче, «дёрганый» |
| `enemy-boss.png` | Босс | 288×384 | тёмно-багровый, рога, крупный и злой |
> Опционально (на будущее, не обязательно сейчас): по 4 ракурса на персонажа
> (вверх/вниз/влево/вправо) — тогда добавь суффиксы `-up/-down/-left/-right`.
### Окружение
| Файл | Что | Рекоменд. размер | Заметки |
|---|---|---|---|
| `floor.png` | Текстура пола | 256×256 | **бесшовная**, светлый серо-песочный камень, без сильных направленных деталей (виден под углом) |
| `wall.png` | Текстура стены | 256×256 | **бесшовная**, сине-серый кирпич |
| `door-closed.png` | Закрытая дверь (засов) | 192×256, 3:4 | стоит в проёме стены; видна во время боя |
| `door-open.png` | Открытая дверь (тёмный проём/арка) | 192×256, 3:4 | после зачистки комнаты |
### Снаряды и эффекты (прозрачный фон, светлый центр, под аддитивное смешивание)
| Файл | Что | Рекоменд. размер | Заметки |
|---|---|---|---|
| `tear.png` | Снаряд-«слеза» игрока | 64×64 | голубая светящаяся капля |
| `muzzle.png` | Вспышка из дула при выстреле | 128×128 | радиальная жёлто-белая |
| `spark.png` | Искра попадания по врагу | 128×128 | бело-жёлтая, короткая |
| `puff.png` | Облачко при гибели врага | 128×128 | серо-белое, рассеивается |
| `shadow.png` | Мягкая тень-«пятно» под сущностью | 128×64 | чёрный радиальный градиент, эллипс |
### Интерфейс и меню (опционально, но желательно)
| Файл | Что | Рекоменд. размер | Заметки |
|---|---|---|---|
| `logo.png` | Логотип «Биндим Фигняшку» | 900×220 | прозрачный фон, для стартового меню |
| `menu-bg.png` | Фон стартового меню | 880×660 | тёмное подземелье, не пёстрый (поверх — текст) |
| `heart-full.png` / `heart-half.png` / `heart-empty.png` | Сердечки HP | 48×48 | замена полоске HP на сердечки (как в Isaac) |
| `icon-ranged.png` / `icon-melee.png` | Иконки режимов (пистолет/нож) | 48×48 | для индикатора оружия в HUD |
---
## Как это подключено (уже работает)
1. PNG лежат в `src/assets/` с именами-ключами (`floor.png`, `player-ranged.png`, …).
2. `src/render/assets.ts` грузит их в рантайме через `THREE.TextureLoader` по пути
`assets/<ключ>.png` (НЕ импорт-бандлингом). Нет файла → процедурный фолбэк `drawX()`.
3. Dev-сервер отдаёт `/assets/*` из `src/assets/`; прод-сборка копирует их в `dist/assets/`.
4. HUD-ассеты (`heart-*`, `icon-*`) грузит `HudOverlay`, меню (`logo`, `menu-bg`) — `index.html`.
**Заменить/добавить:** просто положи PNG с тем же именем в `src/assets/` — подхватится само.
+23 -8
View File
@@ -28,8 +28,10 @@
```
3. **`src/core/systems/spawner.ts`** — реши, когда он спавнится (логика выбора типа
в начале цикла). Напр. с шансом: `rng.chance(0.15) ? 'tank' : rng.chance(ENEMY.fastChance) ? 'fast' : 'normal'`.
4. **`src/render/ThreeRenderer.ts`** — цвет в `COLOR` и выбор цвета/геометрии в
`syncEnemies` (квадрат `unitPlane` или круг `unitCircle`).
4. **`src/render/assets.ts`** — добавь ключ в `SpriteKey` и ветку в `Assets.sprite()`
(рисуется через `drawCharacter(...)` с твоими цветами).
5. **`src/render/ThreeRenderer.ts`** — добавь строку в `enemyMatKey`, сопоставив новый
`Enemy['type']` этому `SpriteKey` (без этого не пройдёт проверка типов).
ИИ, урон, отбрасывание, мигание при попадании — общие, их трогать не нужно.
Добавь тест в `tests/spawner.test.ts`, если ввёл особое правило спавна.
@@ -111,9 +113,21 @@
---
## 8. Кастомные ассеты (своя тема / текстуры)
## 8. Кастомные ассеты (спрайты, текстуры, тема)
Сейчас вид мира — это цвета в **`src/render/theme.ts`** (`Theme` + `DEFAULT_THEME`).
Графика грузится из PNG в **`src/assets/`** (класс `Assets` в `src/render/assets.ts`
тянет `assets/<ключ>.png` через `THREE.TextureLoader`); если файла нет — рисуется
процедурный фолбэк на canvas, и игра не ломается.
- **Свой/новый ассет:** просто положи PNG с именем `<ключ>.png` в **`src/assets/`**
(dev-сервер отдаёт их сразу, прод-сборка копирует в `dist/assets`). Код менять не нужно.
Ключи = именам файлов (`player-ranged`, `enemy-boss`, `floor`, `door-closed`, `tear`,
`heart-full`, `icon-ranged`, …); полный список и промпт для дизайн-ИИ — в **`docs/ASSET_BRIEF.md`**.
- **Запасной рисунок (фолбэк):** функции `drawX()` в `assets.ts` — правь их, только если
нужен другой плейсхолдер на случай отсутствия PNG.
- **Цвета/тинты/фон** (не текстуры) — в **`src/render/theme.ts`** (`Theme` + `DEFAULT_THEME`).
- **HUD-ассеты** (сердечки `heart-*`, иконки `icon-*`) грузит `HudOverlay`; меню (`logo`,
`menu-bg`) — `index.html`. Те же имена в `src/assets/`.
- **Своя палитра:** сделай ещё один объект `Theme` и передай его в
`new ThreeRenderer(canvas, myTheme)` в `main.ts`. Сейчас рендер создаётся один
@@ -133,10 +147,11 @@
Рендер изолирован за интерфейсом **`src/render/Renderer.ts`** (`render(game, alpha)`
+ `dispose()`). Варианты:
- **Доработать вид** (спрайты, текстуры, частицы) — внутри `ThreeRenderer`.
- **Сделать 3D** — поменяй `OrthographicCamera` на `PerspectiveCamera`, добавь
свет и 3D-меши. Мир рисуется по тем же координатам сущностей из `Game` — логику
менять не нужно.
- **Доработать вид** (спрайты, текстуры, частицы) — внутри `ThreeRenderer` (камера
уже наклонная `PerspectiveCamera`, сущности — биллборд-спрайты).
- **Усилить 3D** — для настоящего объёма замени `MeshBasicMaterial` (он без света)
на `MeshStandardMaterial`, добавь источники света и объёмные меши вместо плоскостей.
Мир рисуется по тем же координатам сущностей из `Game` — логику менять не нужно.
- **Другой рендер целиком** (например, Canvas2D для отладки) — реализуй `Renderer`
и подставь в `main.ts`. Ядро не трогается вообще.
+7 -4
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dungeon Crawl — three.js</title>
<title>Биндим Фигняшку</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -27,11 +27,14 @@
/* Стартовое меню — поверх холстов, перекрывает сцену своим фоном. */
#menu {
position: absolute; inset: 0; z-index: 10;
background: #0a0a0fee;
/* фон-картинка с тёмным затемнением для читаемости; фолбэк — тёмный цвет */
background: linear-gradient(rgba(10,10,15,0.7), rgba(10,10,15,0.82)),
url(assets/menu-bg.png) center / cover no-repeat, #0a0a0f;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 14px; padding: 24px;
}
#menu h1 { font-size: 40px; letter-spacing: 4px; color: #c9b27a; }
#menu h1 { font-size: 40px; letter-spacing: 4px; color: #c9b27a; margin-bottom: 4px; }
#menu-logo { display: block; width: min(560px, 80%); height: auto; }
.menu-sub { color: #888; margin-bottom: 6px; }
#menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; }
.menu-preset {
@@ -55,7 +58,7 @@
<!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. -->
<div id="menu">
<h1>DUNGEON CRAWL</h1>
<h1><img id="menu-logo" src="assets/logo.png" alt="Биндим Фигняшку" /></h1>
<p class="menu-sub">Выбери уровень</p>
<div id="menu-presets"></div>
<p class="menu-hint">WASD — движение · Стрелки — стрельба · Tab — оружие · Esc — меню</p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+23 -18
View File
@@ -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
View File
@@ -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();
}
+51 -14
View File
@@ -9,6 +9,18 @@ import type { Renderer } from './Renderer';
*/
export class HudOverlay implements Renderer {
private readonly ctx: CanvasRenderingContext2D;
private readonly images = new Map<string, HTMLImageElement>();
/** Лениво грузит PNG из assets/<name>.png; возвращает картинку, только когда она готова. */
private img(name: string): HTMLImageElement | null {
let im = this.images.get(name);
if (!im) {
im = new Image();
im.src = `assets/${name}.png`;
this.images.set(name, im);
}
return im.complete && im.naturalWidth > 0 ? im : null;
}
constructor(canvas: HTMLCanvasElement) {
// Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI),
@@ -27,8 +39,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 {
@@ -40,31 +52,26 @@ export class HudOverlay implements Renderer {
const p = game.player;
const room = game.curRoom;
// Полоса здоровья.
const bx = 20, by = 20, bw = 140, bh = 14;
ctx.fillStyle = '#111'; ctx.fillRect(bx, by, bw, bh);
ctx.fillStyle = '#2a0a0a'; ctx.fillRect(bx + 2, by + 2, bw - 4, bh - 4);
const hpRatio = Math.max(0, p.hp / p.maxHp);
ctx.fillStyle = hpRatio > 0.5 ? '#993333' : hpRatio > 0.25 ? '#994422' : '#663322';
ctx.fillRect(bx + 2, by + 2, (bw - 4) * hpRatio, bh - 4);
ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.strokeRect(bx, by, bw, bh);
ctx.fillStyle = '#bbb'; ctx.font = '10px monospace'; ctx.textAlign = 'center';
ctx.fillText(`HP ${p.hp}/${p.maxHp}`, bx + bw / 2, by + bh - 3);
// Здоровье: сердечки (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}`, bx, by + bh + 14);
ctx.fillText(`Уровень: ${game.rules.name}`, 20, healthBottom + 14);
// Индикатор режима боя.
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);
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.fillStyle = '#555'; ctx.font = '11px monospace'; ctx.fillText('[Tab] сменить оружие', CW / 2, my - 26);
// Иконка оружия слева в рамке (если ассет есть).
const icon = this.img(ranged ? 'icon-ranged' : 'icon-melee');
if (icon) ctx.drawImage(icon, CW / 2 - 90, my - 14, 26, 26);
// Счётчик врагов / подсказка зачистки.
ctx.textAlign = 'left';
@@ -87,6 +94,36 @@ export class HudOverlay implements Renderer {
}
}
/** Рисует здоровье сердечками (2 HP = сердце); фолбэк — полоса. Возвращает нижний Y. */
private drawHealth(hp: number, maxHp: number): number {
const ctx = this.ctx;
const x0 = 20, y0 = 16;
const full = this.img('heart-full');
const half = this.img('heart-half');
const empty = this.img('heart-empty');
if (full && half && empty) {
const sz = 26, gap = 2;
const slots = Math.ceil(maxHp / 2);
for (let i = 0; i < slots; i++) {
const rem = hp - i * 2;
const im = rem >= 2 ? full : rem === 1 ? half : empty;
ctx.drawImage(im, x0 + i * (sz + gap), y0, sz, sz);
}
return y0 + sz;
}
// Фолбэк — полоса HP.
const by = 20, bw = 140, bh = 14;
ctx.fillStyle = '#111'; ctx.fillRect(x0, by, bw, bh);
ctx.fillStyle = '#2a0a0a'; ctx.fillRect(x0 + 2, by + 2, bw - 4, bh - 4);
const ratio = Math.max(0, hp / maxHp);
ctx.fillStyle = ratio > 0.5 ? '#993333' : ratio > 0.25 ? '#994422' : '#663322';
ctx.fillRect(x0 + 2, by + 2, (bw - 4) * ratio, bh - 4);
ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.strokeRect(x0, by, bw, bh);
ctx.fillStyle = '#bbb'; ctx.font = '10px monospace'; ctx.textAlign = 'center';
ctx.fillText(`HP ${hp}/${maxHp}`, x0 + bw / 2, by + bh - 3);
return by + bh;
}
private drawMinimap(game: Game): void {
const ctx = this.ctx;
const ox = CW - 180, oy = 12, cell = 14, gap = 2, cs = cell + gap;
+256 -111
View File
@@ -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);
// Стены по сторонам с проёмами под двери.
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();
}
}
+237
View File
@@ -0,0 +1,237 @@
import * as THREE from 'three';
/**
* assets.ts — поставщик текстур. Каждая текстура грузится из
* `src/assets/<ключ>.png` через THREE.TextureLoader; если PNG нет — рисуется
* процедурный фолбэк на canvas (функции drawX ниже), чтобы игра работала без
* ассетов. Текстуры кэшируются и освобождаются в dispose().
*
* Как заменить/добавить графику: положи PNG с именем `<ключ>.png` в `src/assets/`
* (dev-сервер отдаёт их из src/assets, прод-сборка копирует в dist/assets). Код
* трогать не нужно. Функции drawX — это лишь плейсхолдер-фолбэк; правь их, только
* если хочешь другой запасной рисунок. Полный список ключей — в docs/ASSET_BRIEF.md.
*/
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 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 readonly loader = new THREE.TextureLoader();
/**
* Возвращает текстуру по ключу. Сначала пытается загрузить PNG из
* `src/assets/<key>.png` (поставляется художником, см. docs/ASSET_BRIEF.md);
* если файла нет — рисует процедурный фолбэк, чтобы игра не ломалась.
* 404 в консоли для ещё не добавленных ассетов — это норма (сработал фолбэк).
*/
private get(key: string, build: () => HTMLCanvasElement, pixelated = true): THREE.Texture {
const cached = this.cache.get(key);
if (cached) return cached;
const tex = this.loader.load(
`assets/${key}.png`,
undefined,
undefined,
() => { tex.image = build() as unknown as HTMLImageElement; tex.needsUpdate = true; }, // PNG нет → процедурный фолбэк
);
tex.colorSpace = THREE.SRGBColorSpace;
if (pixelated) {
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
}
this.cache.set(key, tex);
return tex;
}
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
View File
@@ -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,
};