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:
2026-06-18 15:16:57 +03:00
co-authored by Claude Opus 4.8
parent 5a8cf0e500
commit 3bb46e2f6b
12 changed files with 716 additions and 211 deletions
+7 -5
View File
@@ -7,7 +7,7 @@
## Что это ## Что это
Top-down рогалик (в духе Binding of Isaac). Логика — чистый TypeScript 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), Обзор — [`README.md`](./README.md), детали — [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md),
рецепты доработки — [`docs/HOWTO.md`](./docs/HOWTO.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/world/tiles.ts`, `Room.ts` |
| коллизии | `src/core/systems/collision.ts` | | коллизии | `src/core/systems/collision.ts` |
| расстановку врагов | `src/core/systems/spawner.ts` | | расстановку врагов | `src/core/systems/spawner.ts` |
| как рисуется мир | `src/render/ThreeRenderer.ts` | | как рисуется мир (псевдо-3D) | `src/render/ThreeRenderer.ts` |
| цвета/внешний вид/ассеты мира | `src/render/theme.ts` | | спрайты/текстуры (графика) | `src/render/assets.ts` (бриф на художку — `docs/ASSET_BRIEF.md`) |
| цвета/тинты/фон | `src/render/theme.ts` |
| HUD/миникарту | `src/render/HudOverlay.ts` | | HUD/миникарту | `src/render/HudOverlay.ts` |
| стартовое меню | `src/ui/StartMenu.ts` | | стартовое меню | `src/ui/StartMenu.ts` |
| раскладку клавиш | `src/input/KeyboardController.ts` | | раскладку клавиш | `src/input/KeyboardController.ts` |
@@ -80,8 +81,9 @@ bun run check # typecheck + test
Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте. Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте.
- **`OPP` направлений.** Противоположное к `up` — это `down`, к `left``right`. - **`OPP` направлений.** Противоположное к `up` — это `down`, к `left``right`.
Любая другая раскладка ломает встречные двери и связность карты. Любая другая раскладка ломает встречные двери и связность карты.
- **Чёрный экран при работающем рендере.** Ортокамера инвертирует Y → нужен - **Пол «выворачивает» перспективу.** Пол в псевдо-3D надо класть ПЛАШМЯ
`DoubleSide` на материалах, иначе грани отсекаются. (`flatMesh`, поворот −90° вокруг X). Без поворота он встаёт вертикально и вид
ломается. Спрайты/стены — `DoubleSide` (камера переворачивает Y, иначе грани отсекаются).
- **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`), - **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`),
иначе они показываются в размер HiDPI-буфера. иначе они показываются в размер HiDPI-буфера.
- **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) — - **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) —
+13 -7
View File
@@ -1,12 +1,14 @@
# Dungeon Crawl — рогалик в духе The Binding of Isaac # Биндим Фигняшку — рогалик в духе The Binding of Isaac
Top-down рогалик: процедурный данжен из комнат, два режима боя (дальний/ближний), Top-down рогалик: процедурный данжен из комнат, два режима боя (дальний/ближний),
враги, босс. Логика на чистом TypeScript, рендер — на **three.js** (ортографическая враги, босс. Логика на чистом TypeScript, рендер — на **three.js** в **псевдо-3D**:
камера, картинка плоская 2D-сверху). Сборка — **Bun**. наклонная камера, пол лежит плашмя, персонажи — вертикальные спрайты-биллборды
(как в Isaac). Сборка — **Bun**.
Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed). забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
Внешний вид мира вынесен в «тему» — задел под кастомные ассеты (спрайты/текстуры). Двери видны всегда (закрыты в бою, открыты после зачистки). Графика — процедурные
ассеты в `render/assets.ts`; бриф на полную художку — в `docs/ASSET_BRIEF.md`.
> Это рабочая **база для развития**, а не готовая игра. Архитектура специально > Это рабочая **база для развития**, а не готовая игра. Архитектура специально
> сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам. > сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам.
@@ -44,6 +46,9 @@ bun run check # и то, и другое разом
| R | заново (на экране Game Over / Victory) | | R | заново (на экране Game Over / Victory) |
| Esc | вернуться в меню выбора уровня | | Esc | вернуться в меню выбора уровня |
Клавиши привязаны к **физическим** кнопкам (`event.code`), поэтому WASD/Q/R работают
в любой раскладке (в т.ч. русской), независимо от языка ввода.
Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на
дверь, нажимая в её сторону. дверь, нажимая в её сторону.
@@ -66,7 +71,7 @@ bun run check # и то, и другое разом
## Стек и устройство ## Стек и устройство
- **Bun** — рантайм, бандлер и тест-раннер. - **Bun** — рантайм, бандлер и тест-раннер.
- **three.js** — WebGL-рендер мира через ортокамеру. - **three.js** — WebGL-рендер мира в псевдо-3D (наклонная `PerspectiveCamera`, биллборд-спрайты).
- **TypeScript (strict)** — весь код. - **TypeScript (strict)** — весь код.
- Архитектура: **логика игры полностью отделена от рендера**. Ядро (`src/core/`) - Архитектура: **логика игры полностью отделена от рендера**. Ядро (`src/core/`)
не знает ни про DOM, ни про three.js, поэтому его легко тестировать и при не знает ни про DOM, ни про three.js, поэтому его легко тестировать и при
@@ -94,8 +99,9 @@ src/
│ └── KeyboardController.ts клавиатура → InputState │ └── KeyboardController.ts клавиатура → InputState
├── render/ ── РЕНДЕР (читает состояние, рисует) ── ├── render/ ── РЕНДЕР (читает состояние, рисует) ──
│ ├── Renderer.ts интерфейс рендера │ ├── Renderer.ts интерфейс рендера
│ ├── ThreeRenderer.ts мир на three.js (ортокамера) │ ├── ThreeRenderer.ts мир на three.js (псевдо-3D: наклон + биллборды)
│ ├── theme.ts ВНЕШНИЙ ВИД (цвета; задел под спрайты/текстуры) │ ├── assets.ts ⭐ АССЕТЫ (процедурные спрайты/текстуры → текстуры three.js)
│ ├── theme.ts цвета/тинты мира (фон, эффекты)
│ └── HudOverlay.ts HUD и миникарта на 2D-канвасе поверх │ └── HudOverlay.ts HUD и миникарта на 2D-канвасе поверх
├── ui/ ├── ui/
│ └── StartMenu.ts стартовое меню (DOM) с выбором уровня │ └── StartMenu.ts стартовое меню (DOM) с выбором уровня
+40 -15
View File
@@ -105,26 +105,51 @@
- **`#hud`** — 2D-канвас поверх, `HudOverlay` рисует HP, индикатор режима, - **`#hud`** — 2D-канвас поверх, `HudOverlay` рисует HP, индикатор режима,
счётчик врагов, подпись комнаты, миникарту и оверлеи Game Over/Victory. счётчик врагов, подпись комнаты, миникарту и оверлеи Game Over/Victory.
### ThreeRenderer — ортокамера и «плоское 2D» ### ThreeRenderer — псевдо-3D (наклонный вид, как в Isaac)
- `OrthographicCamera(0, CW, 0, CH, …)` отображает мировые координаты **один в Логика остаётся 2D-сверху, но РЕНДЕР — псевдо-3D:
один** в пиксельные (x вправо, y вниз). Поэтому вся математика ядра валидна без
пересчётов. Слои по `z` (пол < стены < сущности < снаряды).
- Камера переворачивает ось Y → инвертируется порядок вершин → при обычном
отсечении задних граней плоскости были бы невидимы. Поэтому все материалы —
**`DoubleSide`** (правильный выбор для плоских спрайтов).
> **Исторический баг №3.** Именно из-за инверсии Y и отсечения граней мир рисовался - **Карта координат:** игровая точка `(x, y)` кладётся на пол в 3D как `(x, 0, y)`
> «в пустоту» (чёрный экран при работающих draw-call). Лечится `DoubleSide`. — пол это плоскость `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` строит текстуры процедурно на `<canvas>` (спрайты персонажей, пол, стены,
двери, снаряд, эффекты, тень) и кэширует их. Это «стандартный» набор без внешних файлов.
Заменить на свои PNG — описано в `docs/HOWTO.md` и `docs/ASSET_BRIEF.md` (бриф на полную
графику). Ключи ассетов используются и в рендере, и в брифе.
### Эффекты оружия
Рендер сам распознаёт события по состоянию (ядро не трогаем):
- вспышка из дула — когда `player.atkCD` «подскочил» (выстрел);
- искра — когда у врага вырос `hitTimer` (попадание);
- облачко-«пуф» — когда мёртвый враг исчезает из комнаты.
Эффекты — короткоживущие аддитивные спрайты-биллборды, гаснут по таймеру.
### Управление ресурсами GPU (важно — иначе утечки) ### Управление ресурсами GPU (важно — иначе утечки)
- Общие геометрии-«единицы» (`unitPlane`, `unitCircle`) масштабируются под размер - Общие геометрии переиспользуются масштабированием — не плодим геометрии.
сущности — не плодим геометрии. - Группа комнаты (пол/стены/двери) пересобирается только при смене комнаты/`cleared`;
- Тайлы комнаты пересобираются **только при смене комнаты**. персональные материалы дверей `dispose()`-ятся при пересборке.
- Меши сущностей создаются/удаляются по факту появления/исчезновения - Спрайты сущностей и эффекты создаются/удаляются по факту появления/исчезновения
(mark-and-sweep в `sync*`), их персональные материалы корректно `dispose()`-ятся. (mark-and-sweep), их персональные материалы корректно `dispose()`-ятся.
- Общие ресурсы освобождаются один раз в `dispose()`. - Общие материалы/геометрии и `Assets` освобождаются один раз в `dispose()`.
## HiDPI ## HiDPI
+105
View File
@@ -0,0 +1,105 @@
# Бриф на ассеты — «Биндим Фигняшку»
Документ для генерации полного набора графики (через Claude/дизайн-ИИ или художника).
Ниже — **готовый промпт** (можно копировать целиком) и **таблица ассетов**, привязанная
к нашему пайплайну: каждый ассет ложится в `src/render/assets.ts` вместо текущего
процедурного рисунка (заменяем `drawX()` на загрузку PNG через `THREE.TextureLoader`).
Следующим заходом мы подключим присланные PNG; пока в игре — простые процедурные плейсхолдеры.
---
## Готовый промпт (копировать целиком)
> Ты — художник 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/` (новая папка) и импортируем как URL (Bun умеет в `import img from './x.png'`).
2. В `src/render/assets.ts` заменяем процедурные `drawX()` на
`new THREE.TextureLoader().load(url)` с теми же ключами — больше нигде ничего менять не нужно
(рендер берёт текстуры по ключам, см. `docs/ARCHITECTURE.md` → «Тема / ассеты»).
3. При желании заводим тему `Theme` со ссылками на наборы ассетов, чтобы переключать стиль.
Если пришлёте ОДИН атлас вместо отдельных файлов — приложите JSON с прямоугольниками
(`{ "player-ranged": [x,y,w,h], ... }`), мы нарежем через `texture.repeat`/`offset`.
+18 -8
View File
@@ -28,8 +28,10 @@
``` ```
3. **`src/core/systems/spawner.ts`** — реши, когда он спавнится (логика выбора типа 3. **`src/core/systems/spawner.ts`** — реши, когда он спавнится (логика выбора типа
в начале цикла). Напр. с шансом: `rng.chance(0.15) ? 'tank' : rng.chance(ENEMY.fastChance) ? 'fast' : 'normal'`. в начале цикла). Напр. с шансом: `rng.chance(0.15) ? 'tank' : rng.chance(ENEMY.fastChance) ? 'fast' : 'normal'`.
4. **`src/render/ThreeRenderer.ts`** — цвет в `COLOR` и выбор цвета/геометрии в 4. **`src/render/assets.ts`** — добавь ключ в `SpriteKey` и ветку в `Assets.sprite()`
`syncEnemies` (квадрат `unitPlane` или круг `unitCircle`). (рисуется через `drawCharacter(...)` с твоими цветами).
5. **`src/render/ThreeRenderer.ts`** — добавь строку в `enemyMatKey`, сопоставив новый
`Enemy['type']` этому `SpriteKey` (без этого не пройдёт проверка типов).
ИИ, урон, отбрасывание, мигание при попадании — общие, их трогать не нужно. ИИ, урон, отбрасывание, мигание при попадании — общие, их трогать не нужно.
Добавь тест в `tests/spawner.test.ts`, если ввёл особое правило спавна. Добавь тест в `tests/spawner.test.ts`, если ввёл особое правило спавна.
@@ -111,9 +113,16 @@
--- ---
## 8. Кастомные ассеты (своя тема / текстуры) ## 8. Кастомные ассеты (спрайты, текстуры, тема)
Сейчас вид мира — это цвета в **`src/render/theme.ts`** (`Theme` + `DEFAULT_THEME`). Графика (спрайты персонажей/врагов, пол, стены, двери, снаряд, эффекты, тень) рисуется
процедурно в **`src/render/assets.ts`** (класс `Assets`, кэширует текстуры three.js).
- **Свои PNG вместо процедурных:** замени тело нужного `drawX()` на загрузку картинки
`new THREE.TextureLoader().load(url)` и верни её из соответствующего геттера — рендер
берёт текстуры по ключам, больше ничего менять не нужно. Полный список нужных ассетов с
размерами и готовый промпт для дизайн-ИИ — в **`docs/ASSET_BRIEF.md`**.
- **Цвета/тинты/фон** (не текстуры) — в **`src/render/theme.ts`** (`Theme` + `DEFAULT_THEME`).
- **Своя палитра:** сделай ещё один объект `Theme` и передай его в - **Своя палитра:** сделай ещё один объект `Theme` и передай его в
`new ThreeRenderer(canvas, myTheme)` в `main.ts`. Сейчас рендер создаётся один `new ThreeRenderer(canvas, myTheme)` в `main.ts`. Сейчас рендер создаётся один
@@ -133,10 +142,11 @@
Рендер изолирован за интерфейсом **`src/render/Renderer.ts`** (`render(game, alpha)` Рендер изолирован за интерфейсом **`src/render/Renderer.ts`** (`render(game, alpha)`
+ `dispose()`). Варианты: + `dispose()`). Варианты:
- **Доработать вид** (спрайты, текстуры, частицы) — внутри `ThreeRenderer`. - **Доработать вид** (спрайты, текстуры, частицы) — внутри `ThreeRenderer` (камера
- **Сделать 3D** — поменяй `OrthographicCamera` на `PerspectiveCamera`, добавь уже наклонная `PerspectiveCamera`, сущности — биллборд-спрайты).
свет и 3D-меши. Мир рисуется по тем же координатам сущностей из `Game` — логику - **Усилить 3D** — для настоящего объёма замени `MeshBasicMaterial` (он без света)
менять не нужно. на `MeshStandardMaterial`, добавь источники света и объёмные меши вместо плоскостей.
Мир рисуется по тем же координатам сущностей из `Game` — логику менять не нужно.
- **Другой рендер целиком** (например, Canvas2D для отладки) — реализуй `Renderer` - **Другой рендер целиком** (например, Canvas2D для отладки) — реализуй `Renderer`
и подставь в `main.ts`. Ядро не трогается вообще. и подставь в `main.ts`. Ядро не трогается вообще.
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dungeon Crawl — three.js</title> <title>Биндим Фигняшку</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
@@ -55,7 +55,7 @@
<!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. --> <!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. -->
<div id="menu"> <div id="menu">
<h1>DUNGEON CRAWL</h1> <h1>Биндим Фигняшку</h1>
<p class="menu-sub">Выбери уровень</p> <p class="menu-sub">Выбери уровень</p>
<div id="menu-presets"></div> <div id="menu-presets"></div>
<p class="menu-hint">WASD — движение · Стрелки — стрельба · Tab — оружие · Esc — меню</p> <p class="menu-hint">WASD — движение · Стрелки — стрельба · Tab — оружие · Esc — меню</p>
+23 -18
View File
@@ -5,9 +5,14 @@ import type { Dir } from '../core/types';
* Раскладка: WASD — движение, стрелки — прицельная стрельба, пробел — * Раскладка: WASD — движение, стрелки — прицельная стрельба, пробел —
* атака по ходу движения, Tab/Q — смена оружия, R — рестарт. * атака по ходу движения, Tab/Q — смена оружия, R — рестарт.
* *
* Контроллер держит набор зажатых клавиш и «защёлкивает» однократные * ВАЖНО: используем `event.code` (ФИЗИЧЕСКАЯ клавиша), а не `event.key`.
* действия (смена оружия/рестарт). Раз в кадр вызывается poll(), который * `code` не зависит от раскладки, поэтому WASD/Q/R работают и в русской
* собирает InputState и сбрасывает однократные флаги. * раскладке (где те же клавиши дают «цфыв»/«й»/«к»). Стрелки/Tab/пробел в
* `code` называются ArrowUp/Tab/Space.
*
* Контроллер держит набор зажатых клавиш и «защёлкивает» однократные действия
* (смена оружия/рестарт). Раз в кадр вызывается poll(), который собирает
* InputState и сбрасывает однократные флаги.
*/ */
export class KeyboardController { export class KeyboardController {
private held = new Set<string>(); private held = new Set<string>();
@@ -16,18 +21,18 @@ export class KeyboardController {
private attached = false; private attached = false;
private onKeyDown = (e: KeyboardEvent): void => { private onKeyDown = (e: KeyboardEvent): void => {
const k = e.key; const c = e.code;
// Однократные действия ловим по факту нажатия (не по удержанию). // Однократные действия ловим по факту нажатия (не по удержанию).
if (!this.held.has(k)) { if (!this.held.has(c)) {
if (k === 'Tab' || k === 'q' || k === 'Q') this.toggleWeaponEdge = true; if (c === 'Tab' || c === 'KeyQ') this.toggleWeaponEdge = true;
if (k === 'r' || k === 'R') this.restartEdge = true; if (c === 'KeyR') this.restartEdge = true;
} }
this.held.add(k); this.held.add(c);
if (PREVENT.has(k)) e.preventDefault(); if (PREVENT.has(c)) e.preventDefault();
}; };
private onKeyUp = (e: KeyboardEvent): void => { private onKeyUp = (e: KeyboardEvent): void => {
this.held.delete(e.key); this.held.delete(e.code);
}; };
private onBlur = (): void => { private onBlur = (): void => {
@@ -57,14 +62,14 @@ export class KeyboardController {
/** Собрать снимок ввода и сбросить однократные флаги. */ /** Собрать снимок ввода и сбросить однократные флаги. */
poll(): InputState { poll(): InputState {
const down = (k: string) => this.held.has(k); const down = (code: string) => this.held.has(code);
let moveX = 0; let moveX = 0;
let moveY = 0; let moveY = 0;
if (down('w') || down('W')) moveY -= 1; if (down('KeyW')) moveY -= 1;
if (down('s') || down('S')) moveY += 1; if (down('KeyS')) moveY += 1;
if (down('a') || down('A')) moveX -= 1; if (down('KeyA')) moveX -= 1;
if (down('d') || down('D')) moveX += 1; if (down('KeyD')) moveX += 1;
let aimDir: Dir | null = null; let aimDir: Dir | null = null;
if (down('ArrowUp')) aimDir = 'up'; if (down('ArrowUp')) aimDir = 'up';
@@ -76,7 +81,7 @@ export class KeyboardController {
moveX, moveX,
moveY, moveY,
aimDir, aimDir,
attackHeld: down(' ') || down('Spacebar'), attackHeld: down('Space'),
toggleWeapon: this.toggleWeaponEdge, toggleWeapon: this.toggleWeaponEdge,
restart: this.restartEdge, restart: this.restartEdge,
}; };
@@ -87,7 +92,7 @@ export class KeyboardController {
} }
} }
/** Клавиши, у которых гасим стандартное поведение браузера (скролл и т.п.). */ /** Физические клавиши (e.code), у которых гасим поведение браузера (скролл/таб). */
const PREVENT = new Set([ 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); const menu = new StartMenu(menuEl, PRESETS, startGame);
menu.show(); menu.show();
// Esc во время игры — вернуться к выбору уровня. // Esc во время игры — вернуться к выбору уровня (по физической клавише).
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && loop) { if (e.code === 'Escape' && loop) {
e.preventDefault(); e.preventDefault();
toMenu(); toMenu();
} }
+3 -3
View File
@@ -27,8 +27,8 @@ export class HudOverlay implements Renderer {
this.drawHud(game); this.drawHud(game);
this.drawMinimap(game); this.drawMinimap(game);
if (game.gameOver) this.drawOverlay('#c33', 'GAME OVER'); if (game.gameOver) this.drawOverlay('#c33', 'ИГРА ОКОНЧЕНА');
else if (game.won) this.drawOverlay('#3c3', 'VICTORY'); else if (game.won) this.drawOverlay('#3c3', 'ПОБЕДА');
} }
dispose(): void { dispose(): void {
@@ -58,7 +58,7 @@ export class HudOverlay implements Renderer {
// Индикатор режима боя. // Индикатор режима боя.
const my = CH - 46; const my = CH - 46;
const ranged = p.mode === MODE_RANGED; const ranged = p.mode === MODE_RANGED;
const mText = ranged ? 'RANGED' : 'MELEE'; const mText = ranged ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ';
const mCol = ranged ? '#4488cc' : '#cc6644'; const mCol = ranged ? '#4488cc' : '#cc6644';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34); ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
+256 -111
View File
@@ -1,7 +1,7 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { import {
CW, CH, OX, OY, TILE, COLS, ROWS, CW, CH, OX, OY, TILE, COLS, ROWS, RW, RH,
T_WALL, T_DOOR, MODE_RANGED, PROJECTILE, DIR, MODE_RANGED, PROJECTILE, MELEE,
} from '../config'; } from '../config';
import { lerp } from '../core/util'; import { lerp } from '../core/util';
import type { Game } from '../core/Game'; 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 { Projectile } from '../core/entities/Projectile';
import type { Renderer } from './Renderer'; import type { Renderer } from './Renderer';
import { DEFAULT_THEME, type Theme } from './theme'; import { DEFAULT_THEME, type Theme } from './theme';
import { Assets, type SpriteKey } from './assets';
/** Z-слои: больше значение — ближе к камере (рисуется поверх). */
const Z = { floor: 0, wall: 1, door: 0.5, swing: 4, entity: 5, tear: 6 };
/** /**
* Все материалы — DoubleSide. Наша ортокамера переворачивает ось Y * Псевдо-3D рендер «как в Isaac»: наклонная перспективная камера, пол лежит
* (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 вниз), поэтому вся
* математика ядра остаётся валидной без пересчётов.
* *
* Управление ресурсами: * КАРТА КООРДИНАТ: игровая логика остаётся 2D-сверху (x, y). В 3D мы кладём
* • геометрии-«единицы» (unitPlane/unitCircle) общие и переиспользуются * y на ось Z: мировая точка = (x, высота, y). Пол — плоскость Y=0; вверх — +Y.
* масштабированием — не плодим геометрии; * Поэтому ВСЯ математика и КОЛЛИЗИИ ядра без изменений: хитбоксы по-прежнему на
* • тайлы комнаты пересобираются ТОЛЬКО при смене комнаты; * полу (footprint спрайта), псевдо-3D — чисто визуальный слой.
* • меши сущностей создаются/удаляются по мере появления/исчезновения *
* (mark-and-sweep), их персональные материалы корректно dispose-ятся. * Камера фиксированная на комнату (как в 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 { export class ThreeRenderer implements Renderer {
private readonly renderer: THREE.WebGLRenderer; private readonly renderer: THREE.WebGLRenderer;
private readonly scene = new THREE.Scene(); 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 vGeo = new THREE.PlaneGeometry(1, 1); // вертикальный спрайт/стена
private readonly unitCircle = new THREE.CircleGeometry(0.5, 24); 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 roomGroup = new THREE.Group();
private renderedRoom: Room | null = null; private renderedRoom: Room | null = null;
private renderedCleared = false;
// Динамические меши с персональными материалами. // Динамика.
private readonly playerMesh: THREE.Mesh; 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 tearMeshes = new Map<Projectile, THREE.Mesh>();
private readonly swingMesh: THREE.Mesh; private readonly swingMesh: THREE.Mesh;
private readonly effects: Effect[] = [];
private readonly theme: Theme; private lastAtkCD = 0;
constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) { constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) {
this.theme = theme; this.theme = theme;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setSize(CW, CH, false); 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 = new THREE.PerspectiveCamera(44, CW / CH, 1, 4000);
this.camera.position.z = 100; 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 }), const floorTex = this.assets.floor();
floorB: flatMat({ color: this.theme.floorB }), floorTex.wrapS = floorTex.wrapT = THREE.RepeatWrapping;
wall: flatMat({ color: this.theme.wall }), floorTex.repeat.set(COLS, ROWS);
door: flatMat({ color: this.theme.door }), 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.scene.add(this.roomGroup);
this.playerMesh = new THREE.Mesh(this.unitPlane, flatMat({ color: this.theme.playerRanged })); this.playerShadow = this.flatMesh(this.shadowMat);
this.playerMesh.position.z = Z.entity; this.scene.add(this.playerShadow);
this.playerMesh = new THREE.Mesh(this.vGeo, this.playerMat.ranged);
this.scene.add(this.playerMesh); this.scene.add(this.playerMesh);
this.swingMesh = new THREE.Mesh( this.swingMesh = this.flatMesh(
this.unitPlane, new THREE.MeshBasicMaterial({
flatMat({ color: this.theme.swing, transparent: true, opacity: 0.45 }), 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.swingMesh.visible = false;
this.scene.add(this.swingMesh); this.scene.add(this.swingMesh);
} }
render(game: Game, alpha: number): void { render(game: Game, alpha: number): void {
const room = game.curRoom; 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.syncPlayer(game, alpha);
this.syncEnemies(room, alpha); this.syncEnemies(room, alpha);
this.syncTears(room, alpha); this.syncTears(room, alpha);
this.syncSwing(game); this.syncSwing(game);
this.updateEffects();
this.renderer.render(this.scene, this.camera); this.renderer.render(this.scene, this.camera);
} }
// ── Статичная геометрия комнаты ─────────────────────────── // ── Статика комнаты: пол, стены, двери ────────────────────
private buildRoom(room: Room): void { private buildRoom(room: Room): void {
this.clearGroup(this.roomGroup); this.clearGroup(this.roomGroup);
this.renderedRoom = room; this.renderedRoom = room;
this.renderedCleared = room.cleared;
for (let r = 0; r < ROWS; r++) { // Пол — одна плоскость на весь периметр, ПЛАШМЯ (flatMesh кладёт её
for (let c = 0; c < COLS; c++) { // горизонтально; без этого пол стоял бы вертикально и перспектива «выворачивалась»).
const t = room.tiles[r][c]; const floor = this.flatMesh(this.floorMat);
let mat: THREE.MeshBasicMaterial; floor.scale.set(RW, RH, 1);
let z = Z.floor; floor.position.set(OX + RW / 2, 0, OY + RH / 2);
if (t === T_WALL) { mat = this.tileMats.wall; z = Z.wall; } this.roomGroup.add(floor);
else if (t === T_DOOR) { mat = this.tileMats.door; z = Z.door; }
else { mat = (r + c) % 2 === 0 ? this.tileMats.floorA : this.tileMats.floorB; }
const mesh = new THREE.Mesh(this.unitPlane, mat); // Стены по сторонам с проёмами под двери.
mesh.scale.set(TILE, TILE, 1); const xGap: [number, number] | null =
mesh.position.set(OX + c * TILE + TILE / 2, OY + r * TILE + TILE / 2, z); 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); 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 { private syncPlayer(game: Game, alpha: number): void {
const p = game.player; const p = game.player;
const x = lerp(p.prevX, p.x, alpha); const x = lerp(p.prevX, p.x, alpha);
const y = lerp(p.prevY, p.y, alpha); const z = lerp(p.prevY, p.y, alpha);
this.playerMesh.position.set(x, y, Z.entity);
this.playerMesh.scale.set(p.w, p.h, 1);
const base = p.mode === MODE_RANGED ? this.theme.playerRanged : this.theme.playerMelee; this.playerMesh.material = p.mode === MODE_RANGED ? this.playerMat.ranged : this.playerMat.melee;
const flashing = p.invTimer > 0 && p.invTimer % 6 < 3; const w = p.w * SPRITE_SCALE;
(this.playerMesh.material as THREE.MeshBasicMaterial).color.setHex(flashing ? this.theme.flash : base); 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 { private syncEnemies(room: Room, alpha: number): void {
@@ -149,25 +225,44 @@ export class ThreeRenderer implements Renderer {
if (!e.alive) continue; if (!e.alive) continue;
live.add(e); live.add(e);
let mesh = this.enemyMeshes.get(e); let v = this.enemyVisuals.get(e);
if (!mesh) { if (!v) {
const geo = e.type === 'normal' ? this.unitPlane : this.unitCircle; const mat = new THREE.MeshBasicMaterial({
mesh = new THREE.Mesh(geo, flatMat()); map: this.assets.sprite(this.enemyMatKey[e.type]), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide,
mesh.position.z = Z.entity; });
this.scene.add(mesh); const sprite = new THREE.Mesh(this.vGeo, mat);
this.enemyMeshes.set(e, mesh); 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 x = lerp(e.prevX, e.x, alpha);
const y = lerp(e.prevY, e.y, alpha); const z = lerp(e.prevY, e.y, alpha);
mesh.position.set(x, y, Z.entity); const pop = 1 + 0.18 * (e.hitTimer / Math.max(1, MELEE.life)); // «дёргается» при попадании
mesh.scale.set(e.w, e.h, 1); 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; // Искра в момент попадания (hitTimer вырос).
const flashing = e.hitTimer > 0 && e.hitTimer % 4 < 2; if (e.hitTimer > v.lastHit) {
(mesh.material as THREE.MeshBasicMaterial).color.setHex(flashing ? this.theme.flash : base); 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 { private syncTears(room: Room, alpha: number): void {
@@ -175,56 +270,106 @@ export class ThreeRenderer implements Renderer {
for (const t of room.tears) { for (const t of room.tears) {
if (!t.alive) continue; if (!t.alive) continue;
live.add(t); live.add(t);
let mesh = this.tearMeshes.get(t); let mesh = this.tearMeshes.get(t);
if (!mesh) { if (!mesh) {
mesh = new THREE.Mesh(this.unitCircle, flatMat({ color: this.theme.tear })); mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
mesh.position.z = Z.tear; map: this.assets.tear(), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
mesh.scale.set(PROJECTILE.radius * 2, PROJECTILE.radius * 2, 1); }));
const s = PROJECTILE.radius * 4;
mesh.scale.set(s, s, 1);
this.scene.add(mesh); this.scene.add(mesh);
this.tearMeshes.set(t, mesh); this.tearMeshes.set(t, mesh);
} }
mesh.position.set(lerp(t.prevX, t.x, alpha), 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 { private syncSwing(game: Game): void {
const s = game.meleeSwing; const s = game.meleeSwing;
if (!s || !s.alive) { this.swingMesh.visible = false; return; } if (!s || !s.alive) { this.swingMesh.visible = false; return; }
this.swingMesh.visible = true; 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.position.set(s.box.x + s.box.w / 2, 2, s.box.y + s.box.h / 2);
this.swingMesh.scale.set(s.box.w, s.box.h, 1); this.swingMesh.scale.set(s.box.w * 1.4, s.box.h * 1.4, 1);
(this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.45 * (s.life / 10); (this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / MELEE.life);
} }
// ── Утилиты управления ресурсами ────────────────────────── // ── Эффекты (частицы-биллборды) ───────────────────────────
/** Удаляет меши, чьих сущностей больше нет, освобождая их материалы. */ private spawnEffect(
private sweep<K>(map: Map<K, THREE.Mesh>, live: Set<K>): void { tex: THREE.Texture, color: number, x: number, y: number, z: number,
for (const [key, mesh] of map) { size: number, life: number, opts: { vy: number; grow: number },
if (live.has(key)) continue; ): void {
this.scene.remove(mesh); const mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
(mesh.material as THREE.Material).dispose(); map: tex, color: new THREE.Color(color), transparent: true,
map.delete(key); 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 { private clearGroup(group: THREE.Group): void {
// Материалы и геометрия тайлов общие (живут весь срок рендера), for (const child of group.children) {
// поэтому здесь только убираем меши из сцены — без dispose. // Общие материалы (пол/стены) не трогаем; персональные (двери) — освобождаем.
if ((child as THREE.Mesh).userData?.disposable) {
((child as THREE.Mesh).material as THREE.Material).dispose();
}
}
group.clear(); group.clear();
} }
dispose(): void { dispose(): void {
this.clearGroup(this.roomGroup); this.clearGroup(this.roomGroup);
this.sweep(this.enemyMeshes, new Set()); for (const v of this.enemyVisuals.values()) (v.sprite.material as THREE.Material).dispose();
this.sweep(this.tearMeshes, new Set()); for (const m of this.tearMeshes.values()) (m.material as THREE.Material).dispose();
(this.playerMesh.material as THREE.Material).dispose(); for (const fx of this.effects) (fx.mesh.material as THREE.Material).dispose();
(this.swingMesh.material as THREE.Material).dispose(); (this.swingMesh.material as THREE.Material).dispose();
this.unitPlane.dispose(); this.floorMat.dispose();
this.unitCircle.dispose(); this.wallMat.dispose();
for (const m of Object.values(this.tileMats)) m.dispose(); this.shadowMat.dispose();
this.playerMat.ranged.dispose();
this.playerMat.melee.dispose();
this.vGeo.dispose();
this.flatGeo.dispose();
this.assets.dispose();
this.renderer.dispose(); this.renderer.dispose();
} }
} }
+230
View File
@@ -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
View File
@@ -1,48 +1,25 @@
/** /**
* theme.ts — ВНЕШНИЙ ВИД мира (задел под кастомные ассеты). * theme.ts — цвета-ТИНТЫ, которыми пользуется рендер напрямую.
* *
* Сейчас «ассеты» — это просто цвета примитивов (квадраты/круги). Но рендер * Основной внешний вид мира (пол, стены, двери, персонажи, снаряды) теперь живёт
* берёт их отсюда, а не из хардкода, поэтому вид легко подменить, не трогая * в текстурах `render/assets.ts` (процедурные спрайты). Сюда вынесено лишь то, что
* логику: можно завести несколько тем или, в перспективе, расширить Theme * рендер задаёт цветом материала, а не текстурой:
* полями со спрайтами/текстурами (см. комментарий ниже) и научить * • bg — цвет фона (clear color) сцены;
* ThreeRenderer вешать их на материалы. * • swing — тинт спрайта взмаха ближнего боя;
* • flash — тинт вспышек/искр (дуло, попадание).
*
* Хочешь полностью сменить стиль — меняй ассеты (см. `docs/HOWTO.md` и
* `docs/ASSET_BRIEF.md`); хочешь подкрутить фон/эффекты — здесь.
*/ */
export interface Theme { export interface Theme {
/** Цвета (0xRRGGBB) элементов мира. */ bg: number; // фон сцены (0xRRGGBB)
bg: number; swing: number; // тинт взмаха ближнего боя
floorA: number; flash: 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.
} }
/** Тема по умолчанию — текущая «тёмное подземелье». */ /** Тема по умолчанию — «тёмное подземелье». */
export const DEFAULT_THEME: Theme = { export const DEFAULT_THEME: Theme = {
bg: 0x0a0a0f, bg: 0x1c1c28,
floorA: 0x2e2e24,
floorB: 0x353528,
wall: 0x242436,
door: 0x3a2e14,
playerRanged: 0x2a6a9a,
playerMelee: 0x9a3a2a,
enemyNormal: 0x5a4a2e,
enemyFast: 0x992222,
enemyBoss: 0x5a0a0a,
tear: 0x6699cc,
swing: 0xcc8844, swing: 0xcc8844,
flash: 0xdddddd, flash: 0xdddddd,
}; };