Author SHA1 Message Date
mayatnikov b7362a2ad8 refactor: tighten core seams and spawning 2026-06-18 18:34:45 +03:00
Manul 58bf96e9d9 Merge pull request #2 from halofourteen/round2/pseudo-3d
Раунд 2: псевдо-3D + дизайнерский арт, сердечки/иконки, меню («Биндим Фигняшку»)
2026-06-18 16:24:33 +03:00
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
Volodia f4316431e0 add community health files
- LICENSE (MIT)
- CONTRIBUTING.md
- CODE_OF_CONDUCT.md
- SECURITY.md
- .github/PULL_REQUEST_TEMPLATE.md
- .github/ISSUE_TEMPLATE/bug_report.md
- .github/ISSUE_TEMPLATE/feature_request.md
- .github/FUNDING.yml
2026-06-18 14:33:47 +03:00
Manul 5a8cf0e500 Merge pull request #1 from halofourteen/refactor/game-foundation
Рефакторинг: рабочая база на three.js + меню/правила уровней + доки
2026-06-18 14:22:17 +03:00
53 changed files with 1263 additions and 248 deletions
+4
View File
@@ -0,0 +1,4 @@
# Эти строки раскомментируй, если появятся ссылки на спонсорство
# github: [VovaManul]
# patreon: # username
# ko_fi: # username
+34
View File
@@ -0,0 +1,34 @@
---
name: Сообщить о баге
about: Что-то работает не так
title: '[BUG] '
labels: bug
assignees: ''
---
## Описание
<!-- Чёткое описание бага. -->
## Воспроизведение
1. Открыть `dist/index.html` (или `bun run dev`)
2. Выбрать уровень «…»
3. Перейти в комнату …
4. Использовать …
5. Видно, что …
## Ожидаемое поведение
<!-- Что должно было произойти. -->
## Скриншоты / видео
## Среда
- Браузер: Chrome/Firefox/Safari
- ОС: macOS/Windows/Linux
## Дополнительно
<!-- Логи консоли, seed забега и т.д. -->
+29
View File
@@ -0,0 +1,29 @@
---
name: Предложить идею
about: Новая фича, улучшение или изменение
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
## Проблема
<!-- Какая проблема решается? Чего не хватает? -->
## Решение
<!-- Как это должно работать? Опиши желаемое поведение. -->
## Альтернативы
<!-- Какие ещё варианты рассматривал? -->
## Совместимость
- [ ] Меняет архитектуру «ядро ↔ рендер»
- [ ] Добавляет новую зависимость
- [ ] Ломает обратную совместимость
## Дополнительно
<!-- Скетчи, ссылки, примеры из других игр. -->
+28
View File
@@ -0,0 +1,28 @@
## Описание
<!-- Краткое описание изменений. Что и зачем. -->
## Связанный issue
<!-- Fixes #номер -->
## Чеклист
- [ ] `bun run check` зелёный (typecheck + tests)
- [ ] Добавлены/обновлены тесты (если менялась логика)
- [ ] Проверено в браузере (если геймплей/рендер)
- [ ] Обновлена документация (если нужно)
- [ ] Нет `any` (кроме узких мест по согласованию)
- [ ] Нет `Math.random()` в `src/core/`
## Тип изменений
- [ ] Исправление бага
- [ ] Новая функциональность
- [ ] Рефакторинг
- [ ] Документация
- [ ] Другое
## Дополнительно
<!-- Любая дополнительная информация для ревьюера -->
+105
View File
@@ -0,0 +1,105 @@
# AGENTS.md — как работать с этим проектом
Инструкции для ИИ-агентов **и** разработчика. Прочитай целиком перед правками.
Цель проекта — держать **рабочую, расширяемую базу** рогалика. Не ломать то, что
работает; добавлять — по правилам ниже.
## Что это
Top-down рогалик (в духе Binding of Isaac). Логика — чистый TypeScript
(`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).
## 🔑 Золотые правила (нарушать = ломать архитектуру)
1. **Ядро без рендера и DOM.** В `src/core/**` НЕЛЬЗЯ импортировать `three`,
обращаться к `window`/`document`/`canvas`. Логика общается с миром только через
`InputState` (вход) и публичные поля `Game` (для чтения рендером).
2. **Рендер ничего не меняет в игре.** `src/render/**` только ЧИТАЕТ `Game` и
рисует. Любая мутация состояния из рендера — баг.
3. **Случайность только через `Rng`.** Никаких `Math.random()` в `core/`. Это
сохраняет детерминизм (тесты, отладка по seed).
4. **Время — в «шагах» (1/60 c), а не в кадрах.** Скорости — «пиксели/шаг»,
перезарядки — «шаги». Не двигай ничего в коде рендера или прямо в rAF.
5. **Числа — не в коде.** Константы движка (размеры, геометрия дверей, базовый
баланс) — в `src/config.ts`; параметры конкретного забега (размер карты, сила
врагов, HP игрока, seed) — в правилах уровня `src/core/rules.ts`. Не раскидывай
«магические числа» по логике.
6. **Освобождай ресурсы three.js.** Создаёшь геометрию/материал на сущность —
обеспечь `dispose()` при её удалении (см. `sync*`/`sweep` в `ThreeRenderer`).
7. **Комментарии и текст для игрока — по-русски**, как в существующем коде.
## Команды
```bash
bun run dev # дев-сервер + watch → http://localhost:3000
bun run build # прод-сборка в dist/
bun run typecheck # tsc --noEmit (строгий) — НЕ ловится при bun build!
bun test # юнит-тесты ядра
bun run check # typecheck + test
```
> ⚠️ `bun build` **не проверяет типы**. Поэтому `bun run typecheck` обязателен —
> именно отсутствие тайп-чека когда-то скрыло рантайм-регрессию.
## Definition of Done (для любой правки)
1. `bun run check` зелёный (типы + тесты).
2. Если менял логику — **добавил/обновил тест** в `tests/`.
3. Если менял геймплей/рендер — **проверил в браузере** (`bun run dev`, открыть
страницу, увидеть, что играется, в консоли нет ошибок). Юнит-тесты не видят
рендер — визуальную проверку не пропускать.
4. Обновил доки, если поменялось поведение или структура.
## Где что лежит (карта для навигации)
| Хочешь поменять… | Иди в… |
|---|---|
| константы движка (размер тайла/комнаты, геометрия дверей, базовый баланс) | `src/config.ts` |
| правила уровня / пресеты в меню (размер карты, сила врагов, HP, seed) | `src/core/rules.ts` |
| поведение за один шаг (движение, атака, ИИ, переходы) | `src/core/Game.ts` |
| данные сущности | `src/core/entities/*` |
| генерацию карты | `src/core/world/RoomMap.ts` |
| форму комнаты/тайлы | `src/core/world/tiles.ts`, `Room.ts` |
| коллизии | `src/core/systems/collision.ts` |
| расстановку врагов | `src/core/systems/spawner.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` |
| тайминг/цикл, поток меню↔игра | `src/engine/GameLoop.ts`, `src/main.ts` |
Пошаговые рецепты («добавить врага», «новое оружие», «тип комнаты», «сменить
рендер») — в [`docs/HOWTO.md`](./docs/HOWTO.md).
## Грабли, на которые уже наступали (не повторять)
- **Пустая карта.** `new RoomMap(rng)` ДОЛЖЕН генерировать карту в конструкторе.
Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте.
- **`OPP` направлений.** Противоположное к `up` — это `down`, к `left``right`.
Любая другая раскладка ломает встречные двери и связность карты.
- **Пол «выворачивает» перспективу.** Пол в псевдо-3D надо класть ПЛАШМЯ
(`flatMesh`, поворот −90° вокруг X). Без поворота он встаёт вертикально и вид
ломается. Спрайты/стены — `DoubleSide` (камера переворачивает Y, иначе грани отсекаются).
- **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`),
иначе они показываются в размер HiDPI-буфера.
- **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) —
она должна стать `cleared` сразу при входе, иначе двери не появятся.
## Стиль кода
- TypeScript strict, без `any` (кроме узких мест вроде `window as …` в `main.ts`).
- Маленькие чистые функции для логики; классы — для сущностей/состояния.
- Имена и комментарии осмысленные, по-русски. Комментарий объясняет «почему», а не «что».
- Перед коммитом — `bun run check`.
## Чего НЕ делать без явной просьбы
- Не добавлять тяжёлые зависимости (физдвижки, фреймворки). База намеренно лёгкая.
- Не переписывать архитектуру «ядро ↔ рендер».
- Не коммитить `dist/` и `node_modules/` (см. `.gitignore`).
- Не превращать игру в полноценное 3D, пока этого не попросили (рендер для этого
готов — он изолирован, — но это отдельная большая задача).
+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 врагов (сокровищница) —
+52
View File
@@ -0,0 +1,52 @@
# Кодекс поведения
## Наши обязательства
Мы, как участники и лидеры этого проекта, обязуемся создать гостеприимную
и безопасную среду для всех, независимо от возраста, телосложения,
видимых или невидимых физических особенностей, этнической принадлежности,
половых признаков, уровня опыта, образования, социально-экономического
статуса, национальности, внешности, расы, религии или сексуальной
идентичности и ориентации.
Мы обязуемся действовать и взаимодействовать так, чтобы способствовать
открытому, доброжелательному, инклюзивному и здоровому сообществу.
## Приемлемое поведение
Примеры поведения, способствующего созданию положительной среды:
- Проявление доброты и эмпатии по отношению к другим участникам
- Уважение к разным точкам зрения и опыту
- Конструктивная обратная связь
- Признание своих ошибок и извлечение уроков
## Неприемлемое поведение
- Использование сексуализированного языка или образов
- Троллинг, оскорбительные/уничижительные комментарии
- Преследование в любой форме
- Публикация личной информации без явного разрешения
- Другое поведение, которое можно обоснованно считать неуместным
## Ответственность
Лидеры проекта отвечают за разъяснение стандартов поведения и будут
предпринимать соответствующие и справедливые меры в ответ на любое
неприемлемое поведение.
## Сфера действия
Этот Кодекс поведения применяется во всех сообществах проекта, а также
в публичных местах, когда человек представляет проект.
## Enforcement
О случаях неприемлемого поведения можно сообщать лидерам проекта.
Все жалобы будут рассмотрены и приведут к ответным мерам, которые
сочтут необходимыми и соответствующими обстоятельствам.
## Атрибуция
Этот Кодекс поведения адаптирован из [Contributor Covenant](https://www.contributor-covenant.org),
версия 2.1.
+49
View File
@@ -0,0 +1,49 @@
# Как вносить изменения
Спасибо, что хочешь улучшить проект! Это живая база рогалика, и любая помощь
пригодится. Пожалуйста, следуй правилам ниже — они сохраняют архитектуру.
## Прежде чем писать код
1. Прочитай [`CLAUDE.md`](./CLAUDE.md) — там золотые правила архитектуры.
2. Почитай [`docs/HOWTO.md`](./docs/HOWTO.md) — там рецепты для типовых задач.
3. Посмотри [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) — пойми потоки данных.
## Процесс
1. Создай issue (баг или фича) — чтобы обсудить до начала работы.
2. Форкни репозиторий и создай ветку от `main`:
```bash
git checkout -b feature/что-делаешь
```
3. Пиши код, следуя стилю проекта:
- TypeScript strict, без `any`
- Комментарии по-русски, объясняют «почему», а не «что»
- Маленькие чистые функции; классы — для сущностей/состояния
4. Убедись, что всё зелёное:
```bash
bun run check # typecheck + tests
```
5. Если менял логику — **добавь/обнови тест** в `tests/`.
6. Если менял геймплей/рендер — **проверь в браузере** (`bun run dev`).
7. Обнови доки, если поменялось поведение или структура.
8. Создай Pull Request с описанием изменений.
## Золотые правила (нарушать = ломать)
1. **Ядро без рендера и DOM.** В `src/core/` нельзя импортировать `three`,
обращаться к `window`/`document`/`canvas`.
2. **Рендер ничего не меняет в игре.** `src/render/` только читает и рисует.
3. **Случайность только через `Rng`.** Никаких `Math.random()` в `core/`.
4. **Время — в шагах (1/60 с).** Не двигай ничего в rAF или рендере.
5. **Числа — не в коде.** Константы — в `src/config.ts`, параметры забега —
в `src/core/rules.ts`.
6. **Освобождай ресурсы three.js** — `dispose()` геометрию/материалы при удалении.
7. **Перед коммитом — `bun run check`.**
## Чего не делать
- Не добавлять тяжёлые зависимости (физдвижки, фреймворки).
- Не переписывать архитектуру «ядро ↔ рендер».
- Не коммитить `dist/` и `node_modules/`.
- Не превращать игру в полноценное 3D без обсуждения.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 VovaManul
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14 -7
View File
@@ -1,12 +1,15 @@
# 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).
Внешний вид мира вынесен в «тему» — задел под кастомные ассеты (спрайты/текстуры). Двери видны всегда (закрыты в бою, открыты после зачистки). Графика — PNG-ассеты в
`src/assets/` (грузятся в `render/assets.ts`; при отсутствии файла — процедурный
фолбэк). Бриф на художку — `docs/ASSET_BRIEF.md`.
> Это рабочая **база для развития**, а не готовая игра. Архитектура специально > Это рабочая **база для развития**, а не готовая игра. Архитектура специально
> сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам. > сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам.
@@ -44,6 +47,9 @@ bun run check # и то, и другое разом
| R | заново (на экране Game Over / Victory) | | R | заново (на экране Game Over / Victory) |
| Esc | вернуться в меню выбора уровня | | Esc | вернуться в меню выбора уровня |
Клавиши привязаны к **физическим** кнопкам (`event.code`), поэтому WASD/Q/R работают
в любой раскладке (в т.ч. русской), независимо от языка ввода.
Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на
дверь, нажимая в её сторону. дверь, нажимая в её сторону.
@@ -66,7 +72,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 +100,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) с выбором уровня
+24
View File
@@ -0,0 +1,24 @@
# Политика безопасности
## Поддерживаемые версии
Активно разрабатывается только `main`-ветка. Стабильных релизов пока нет.
| Версия | Поддержка |
|---|---|
| main (активная разработка) | ✅ |
| Другие ветки | ❌ |
## Сообщить об уязвимости
Это локальная однопользовательская игра на Canvas/WebGL. Уязвимости
безопасности маловероятны, но если найдёшь что-то серьёзное:
- **Не создавай публичный issue.**
- Напиши в [issues](https://github.com/VovaManul/Binding-Fignyaac/issues)
с пометкой `[SECURITY]` в заголовке — репозиторий приватный/маленький,
это безопасно.
По умолчанию: игра не принимает пользовательский ввод, кроме клавиатуры,
и не выполняет удалённый код. Риск сводится к XSS через `dist/index.html`
при сборке — убедись, что билд чистый (`bun run build` из свежего клона).
+10
View File
@@ -3,6 +3,7 @@
* плюс копия index.html. Открывай dist/index.html. * плюс копия index.html. Открывай dist/index.html.
*/ */
import { build } from 'bun'; import { build } from 'bun';
import { cp, mkdir } from 'node:fs/promises';
const result = await build({ const result = await build({
entrypoints: ['./src/main.ts'], entrypoints: ['./src/main.ts'],
@@ -19,4 +20,13 @@ if (!result.success) {
} }
await Bun.write('./dist/index.html', await Bun.file('./index.html').text()); 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)'); console.log('Сборка готова → dist/ (открой dist/index.html)');
+1
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "dungeon-crawl", "name": "dungeon-crawl",
+6
View File
@@ -22,6 +22,12 @@ const server = Bun.serve({
if (url.pathname === '/' || url.pathname === '/index.html') { if (url.pathname === '/' || url.pathname === '/index.html') {
return new Response(Bun.file('./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); const file = Bun.file('./dist' + url.pathname);
if (await file.exists()) return new Response(file); if (await file.exists()) return new Response(file);
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
+43 -16
View File
@@ -37,7 +37,8 @@
Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь: зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
1. Раз в кадр опрашиваем ввод (`controller.poll()`). 1. Раз в кадр опрашиваем источник ввода (`controller.poll()`). Цикл знает только
маленький интерфейс `poll(): InputState`, а не конкретную клавиатуру.
2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`. 2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`.
3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными 3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными
порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с. порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
@@ -105,26 +106,52 @@
- **`#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` грузит текстуры из `src/assets/<ключ>.png` (спрайты персонажей, пол, стены,
двери, снаряд, эффекты, тень) и кэширует их; если PNG нет — рисует процедурный фолбэк
на `<canvas>`, поэтому игра не ломается без ассетов. Dev-сервер отдаёт `/assets/*` из
`src/assets/`, прод-сборка копирует их в `dist/assets/`. Подмена/добавление графики —
`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/дизайн-ИИ или художника).
Ниже — **готовый промпт** (можно копировать целиком) и **таблица ассетов**.
> ✅ **Статус: набор уже подключён.** 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`** — реши, когда он спавнится (логика выбора типа 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,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` и передай его в - **Своя палитра:** сделай ещё один объект `Theme` и передай его в
`new ThreeRenderer(canvas, myTheme)` в `main.ts`. Сейчас рендер создаётся один `new ThreeRenderer(canvas, myTheme)` в `main.ts`. Сейчас рендер создаётся один
@@ -133,10 +147,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`. Ядро не трогается вообще.
+7 -4
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 {
@@ -27,11 +27,14 @@
/* Стартовое меню — поверх холстов, перекрывает сцену своим фоном. */ /* Стартовое меню — поверх холстов, перекрывает сцену своим фоном. */
#menu { #menu {
position: absolute; inset: 0; z-index: 10; 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; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 14px; padding: 24px; 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-sub { color: #888; margin-bottom: 6px; }
#menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; } #menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; }
.menu-preset { .menu-preset {
@@ -55,7 +58,7 @@
<!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. --> <!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. -->
<div id="menu"> <div id="menu">
<h1>DUNGEON CRAWL</h1> <h1><img id="menu-logo" src="assets/logo.png" alt="Биндим Фигняшку" /></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>
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

+7
View File
@@ -28,6 +28,11 @@ export const OY = 80; // отступ комнаты сверху (м
export const FIXED_FPS = 60; export const FIXED_FPS = 60;
export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг
export const GAME_LOOP = {
maxFrameTimeSec: 0.25, // не «отыгрывать» долгие паузы (фон/таб)
maxStepsPerFrame: 5, // защита от «спирали смерти» при лагах
};
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Типы тайлов // Типы тайлов
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@@ -88,6 +93,7 @@ export const PLAYER = {
speed: 3.2, // px за шаг speed: 3.2, // px за шаг
maxHp: 6, maxHp: 6,
invFrames: 60, // неуязвимость после удара, в шагах invFrames: 60, // неуязвимость после удара, в шагах
entryInvFrames: 20, // короткая неуязвимость при входе в комнату, в шагах
rangedCooldown: 10, // перезарядка выстрела, в шагах rangedCooldown: 10, // перезарядка выстрела, в шагах
meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
transitionLock: 15, // блок повторного перехода между комнатами, в шагах transitionLock: 15, // блок повторного перехода между комнатами, в шагах
@@ -137,6 +143,7 @@ export const SPAWN = {
minDistFromDoor: 180, // не спавнить ближе к двери входа minDistFromDoor: 180, // не спавнить ближе к двери входа
minDistFromPlayer: 150, minDistFromPlayer: 150,
minDistBetween: 60, minDistBetween: 60,
maxPlacementTries: 100,
treasureChance: 0.12, // шанс комнаты-сокровищницы treasureChance: 0.12, // шанс комнаты-сокровищницы
bossChance: 0.2, // шанс назначить комнату боссом bossChance: 0.2, // шанс назначить комнату боссом
}; };
+1 -1
View File
@@ -128,7 +128,7 @@ export class Game {
const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE; const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
this.player.place(px, py); this.player.place(px, py);
this.player.facing = fromDir; this.player.facing = fromDir;
this.player.invTimer = 20; // короткая неуязвимость на входе this.player.invTimer = PLAYER.entryInvFrames;
this.player.transCD = PLAYER.transitionLock; this.player.transCD = PLAYER.transitionLock;
this.meleeSwing = null; this.meleeSwing = null;
+23 -3
View File
@@ -12,9 +12,9 @@ export class Rng {
private state: number; private state: number;
/** Без seed — случайный старт; с seed — детерминированная цепочка. */ /** Без seed — случайный старт; с seed — детерминированная цепочка. */
constructor(seed?: number) { constructor(seed: number = autoSeed()) {
// 0 — валидный seed, поэтому проверяем именно на undefined. // 0 — валидный seed; авто-seed включается только когда аргумент не передан.
this.state = (seed === undefined ? (Math.random() * 2 ** 32) >>> 0 : seed) >>> 0; this.state = seed >>> 0;
} }
/** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */ /** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
@@ -43,6 +43,7 @@ export class Rng {
/** Случайный элемент массива. */ /** Случайный элемент массива. */
pick<T>(arr: readonly T[]): T { pick<T>(arr: readonly T[]): T {
if (arr.length === 0) throw new Error('Rng.pick: пустой массив');
return arr[this.int(0, arr.length - 1)]; return arr[this.int(0, arr.length - 1)];
} }
@@ -55,3 +56,22 @@ export class Rng {
return arr; return arr;
} }
} }
const AUTO_SEED_STEP = 0x9e3779b9;
let autoSeedCounter = 0;
/**
* Seed для нового нефиксированного забега. Энтропия берётся только внутри Rng:
* остальная игровая логика по-прежнему получает все случайные числа через next().
*/
function autoSeed(): number {
autoSeedCounter = (autoSeedCounter + AUTO_SEED_STEP) >>> 0;
let seed = Date.now() >>> 0;
const crypto = globalThis.crypto;
if (crypto?.getRandomValues) {
const value = new Uint32Array(1);
crypto.getRandomValues(value);
seed ^= value[0];
}
return (seed ^ autoSeedCounter) >>> 0;
}
+20 -10
View File
@@ -6,6 +6,23 @@ import type { Dir, EnemyType } from '../types';
import type { Rng } from '../rng'; import type { Rng } from '../rng';
import { DEFAULT_RULES, type LevelRules } from '../rules'; import { DEFAULT_RULES, type LevelRules } from '../rules';
function isSpawnSpotClear(
x: number,
y: number,
doorX: number,
doorY: number,
playerX: number,
playerY: number,
enemies: readonly Enemy[],
): boolean {
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) return false;
if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) return false;
for (const e of enemies) {
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) return false;
}
return true;
}
/** /**
* Подбирает врагов для комнаты и расставляет их так, чтобы они не появились * Подбирает врагов для комнаты и расставляет их так, чтобы они не появились
* вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов * вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов
@@ -43,19 +60,12 @@ export function spawnEnemies(
let x = 0; let x = 0;
let y = 0; let y = 0;
let ok = false; let ok = false;
for (let tries = 0; tries < 100 && !ok; tries++) { for (let tries = 0; tries < SPAWN.maxPlacementTries && !ok; tries++) {
x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE; x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE; y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
ok = true; ok = isSpawnSpotClear(x, y, doorX, doorY, playerX, playerY, enemies);
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) ok = false;
else if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) ok = false;
else {
for (const e of enemies) {
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) { ok = false; break; }
}
}
} }
if (!ok) continue;
enemies.push(new Enemy(x, y, type, mods)); enemies.push(new Enemy(x, y, type, mods));
} }
+9 -7
View File
@@ -1,6 +1,6 @@
import { FIXED_DT } from '../config'; import { FIXED_DT, GAME_LOOP } from '../config';
import type { Game } from '../core/Game'; import type { Game } from '../core/Game';
import type { KeyboardController } from '../input/KeyboardController'; import type { InputSource } from '../input/InputState';
/** /**
* Игровой цикл с ФИКСИРОВАННЫМ шагом. * Игровой цикл с ФИКСИРОВАННЫМ шагом.
@@ -15,11 +15,10 @@ export class GameLoop {
private last = 0; private last = 0;
private rafId = 0; private rafId = 0;
private running = false; private running = false;
private readonly maxSteps = 5; // защита от «спирали смерти» при лагах
constructor( constructor(
private readonly game: Game, private readonly game: Game,
private readonly controller: KeyboardController, private readonly controller: InputSource,
private readonly onRender: (alpha: number) => void, private readonly onRender: (alpha: number) => void,
) {} ) {}
@@ -31,16 +30,19 @@ export class GameLoop {
} }
stop(): void { stop(): void {
if (!this.running) return;
this.running = false; this.running = false;
cancelAnimationFrame(this.rafId); cancelAnimationFrame(this.rafId);
this.rafId = 0;
} }
private frame = (now: number): void => { private frame = (now: number): void => {
if (!this.running) return;
this.rafId = requestAnimationFrame(this.frame); this.rafId = requestAnimationFrame(this.frame);
let frameTime = (now - this.last) / 1000; let frameTime = (now - this.last) / 1000;
this.last = now; this.last = now;
if (frameTime > 0.25) frameTime = 0.25; // не «отыгрывать» долгие паузы (фон/таб) if (frameTime > GAME_LOOP.maxFrameTimeSec) frameTime = GAME_LOOP.maxFrameTimeSec;
// Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр. // Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр.
const input = this.controller.poll(); const input = this.controller.poll();
@@ -48,12 +50,12 @@ export class GameLoop {
this.accumulator += frameTime; this.accumulator += frameTime;
let steps = 0; let steps = 0;
while (this.accumulator >= FIXED_DT && steps < this.maxSteps) { while (this.accumulator >= FIXED_DT && steps < GAME_LOOP.maxStepsPerFrame) {
this.game.step(input); this.game.step(input);
this.accumulator -= FIXED_DT; this.accumulator -= FIXED_DT;
steps++; steps++;
} }
if (steps === this.maxSteps) this.accumulator = 0; // отстали — ресинхронизируемся if (steps === GAME_LOOP.maxStepsPerFrame) this.accumulator = 0; // отстали — ресинхронизируемся
const alpha = this.accumulator / FIXED_DT; const alpha = this.accumulator / FIXED_DT;
this.onRender(alpha); this.onRender(alpha);
+5
View File
@@ -19,6 +19,11 @@ export interface InputState {
restart: boolean; // рестарт на экране конца игры (однократно) restart: boolean; // рестарт на экране конца игры (однократно)
} }
/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */
export interface InputSource {
poll(): InputState;
}
/** Нейтральный снимок — ничего не нажато. */ /** Нейтральный снимок — ничего не нажато. */
export function emptyInput(): InputState { export function emptyInput(): InputState {
return { return {
+25 -20
View File
@@ -1,33 +1,38 @@
import type { InputState } from './InputState'; import type { InputSource, InputState } from './InputState';
import type { Dir } from '../core/types'; 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 implements InputSource {
private held = new Set<string>(); private held = new Set<string>();
private toggleWeaponEdge = false; private toggleWeaponEdge = false;
private restartEdge = false; private restartEdge = false;
private 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();
} }
+51 -14
View File
@@ -9,6 +9,18 @@ import type { Renderer } from './Renderer';
*/ */
export class HudOverlay implements Renderer { export class HudOverlay implements Renderer {
private readonly ctx: CanvasRenderingContext2D; 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) { constructor(canvas: HTMLCanvasElement) {
// Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI), // Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI),
@@ -27,8 +39,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 {
@@ -40,31 +52,26 @@ export class HudOverlay implements Renderer {
const p = game.player; const p = game.player;
const room = game.curRoom; const room = game.curRoom;
// Полоса здоровья. // Здоровье: сердечки (2 HP = сердце), с фолбэком на полосу.
const bx = 20, by = 20, bw = 140, bh = 14; const healthBottom = this.drawHealth(p.hp, p.maxHp);
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);
// Название текущего уровня (правил). // Название текущего уровня (правил).
ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace'; ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace';
ctx.fillText(`Уровень: ${game.rules.name}`, bx, by + bh + 14); ctx.fillText(`Уровень: ${game.rules.name}`, 20, healthBottom + 14);
// Индикатор режима боя. // Индикатор режима боя.
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);
ctx.strokeStyle = mCol; ctx.lineWidth = 2; ctx.strokeRect(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 = 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); 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'; 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 { private drawMinimap(game: Game): void {
const ctx = this.ctx; const ctx = this.ctx;
const ox = CW - 180, oy = 12, cell = 14, gap = 2, cs = cell + gap; 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 * 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();
} }
} }
+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 — цвета-ТИНТЫ, которыми пользуется рендер напрямую.
* *
* Сейчас «ассеты» — это просто цвета примитивов (квадраты/круги). Но рендер * Основной внешний вид мира (пол, стены, двери, персонажи, снаряды) теперь живёт
* берёт их отсюда, а не из хардкода, поэтому вид легко подменить, не трогая * в текстурах `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,
}; };
+17
View File
@@ -41,4 +41,21 @@ describe('Rng', () => {
const shuffled = r.shuffle([...arr]); const shuffled = r.shuffle([...arr]);
expect([...shuffled].sort()).toEqual(arr); expect([...shuffled].sort()).toEqual(arr);
}); });
it('авто-seed не вызывает Math.random()', () => {
const math = Math as Math & { random: () => number };
const original = math.random;
math.random = () => { throw new Error('Math.random вызван'); };
try {
const r = new Rng();
expect(r.next()).toBeGreaterThanOrEqual(0);
expect(r.next()).toBeLessThan(1);
} finally {
math.random = original;
}
});
it('pick явно ругается на пустой массив', () => {
expect(() => new Rng(1).pick([])).toThrow('пустой массив');
});
}); });
+31
View File
@@ -4,6 +4,17 @@ import { Room } from '../src/core/world/Room';
import { Rng } from '../src/core/rng'; import { Rng } from '../src/core/rng';
import { OX, OY, TILE, DOOR, SPAWN } from '../src/config'; import { OX, OY, TILE, DOOR, SPAWN } from '../src/config';
import { dist } from '../src/core/util'; import { dist } from '../src/core/util';
import { DEFAULT_RULES, type LevelRules } from '../src/core/rules';
const denseRules: LevelRules = {
...DEFAULT_RULES,
id: 'dense-test',
name: 'Тестовая тесная комната',
description: 'Много врагов, чтобы проверить отказ от плохих позиций спавна.',
map: { ...DEFAULT_RULES.map },
player: { ...DEFAULT_RULES.player },
enemies: { ...DEFAULT_RULES.enemies, densityMul: 40 },
};
describe('spawner', () => { describe('spawner', () => {
it('в обычной комнате врагов в ожидаемом диапазоне', () => { it('в обычной комнате врагов в ожидаемом диапазоне', () => {
@@ -40,4 +51,24 @@ describe('spawner', () => {
} }
} }
}); });
it('РЕГРЕССИЯ: при переполнении не ставит врага в последнюю плохую точку', () => {
const room = new Room(1, 0, 'normal');
const playerX = OX + 7 * TILE;
const playerY = OY + 5 * TILE;
const door = DOOR.left;
const doorX = OX + door.cx * TILE + TILE / 2;
const doorY = OY + door.cy * TILE + TILE / 2;
const enemies = spawnEnemies(room, 'left', playerX, playerY, new Rng(12), denseRules);
expect(enemies.length).toBeGreaterThan(0);
for (let i = 0; i < enemies.length; i++) {
const e = enemies[i];
expect(dist(e.x, e.y, doorX, doorY)).toBeGreaterThanOrEqual(SPAWN.minDistFromDoor);
expect(dist(e.x, e.y, playerX, playerY)).toBeGreaterThanOrEqual(SPAWN.minDistFromPlayer);
for (let j = i + 1; j < enemies.length; j++) {
expect(dist(e.x, e.y, enemies[j].x, enemies[j].y)).toBeGreaterThanOrEqual(SPAWN.minDistBetween);
}
}
});
}); });