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
mayatnikovandClaude Opus 4.8 0684368ac7 refactor: рабочая база на three.js + меню/правила уровней + доки
Полный рефакторинг проекта в стабильную расширяемую базу рогалика.

Почему: после разнесения single-file на модули потерялся вызов генерации
карты (RoomMap не генерировал комнаты) → игра падала на старте; баг скрывался
тем, что Bun-бандлер не проверяет типы.

Архитектура:
- Логика игры (src/core/) полностью отделена от рендера: без three.js и DOM,
  тестируется без браузера.
- Рендер мира на three.js с ортокамерой (2D-вид); HUD/миникарта — 2D-канвас поверх.
- Фиксированный игровой цикл 60 Гц + интерполяция (раньше скорость зависела
  от частоты кадров).
- Seeded-RNG, ввод через абстрактные «намерения» (InputState).

Возможности:
- Стартовое меню с выбором уровня (Esc → меню).
- Конфигуратор уровней: LevelRules + 5 пресетов (размер карты, плотность/сила
  врагов, HP, фиксированный seed).
- Тема внешнего вида (render/theme.ts) — задел под кастомные ассеты.

Качество:
- 27 юнит-тестов ядра (генерация, симметрия дверей, коллизии, спавн, правила).
- Два круга adversarial-ревью; исправлено 6 реальных багов
  (кнокбэк сквозь стены → софт-лок; незакрываемая сокровищница; фикс-сид после
  рестарта; перенос ввода между забегами; нет source maps; неточности в доках).
- Документация: README, docs/ARCHITECTURE.md, CLAUDE.md, docs/HOWTO.md.
- dist/ исключён из гита; bun.lock зафиксирован.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:07:06 +03:00
92 changed files with 3773 additions and 3363 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/`
## Тип изменений
- [ ] Исправление бага
- [ ] Новая функциональность
- [ ] Рефакторинг
- [ ] Документация
- [ ] Другое
## Дополнительно
<!-- Любая дополнительная информация для ревьюера -->
+1 -1
View File
@@ -1,4 +1,4 @@
node_modules/
dist/*.map
dist/
*.log
.DS_Store
+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, пока этого не попросили (рендер для этого
готов — он изолирован, — но это отдельная большая задача).
+105
View File
@@ -0,0 +1,105 @@
# CLAUDE.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, пока этого не попросили (рендер для этого
готов — он изолирован, — но это отдельная большая задача).
+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.
+93 -71
View File
@@ -1,94 +1,116 @@
# Dungeon Crawl — Ranged / Melee
# Биндим Фигняшку — рогалик в духе The Binding of Isaac
A dark fantasy dungeon crawler inspired by *The Binding of Isaac*.
Built with TypeScript and Canvas 2D, bundled with **Bun**.
Top-down рогалик: процедурный данжен из комнат, два режима боя (дальний/ближний),
враги, босс. Логика на чистом TypeScript, рендер — на **three.js** в **псевдо-3D**:
наклонная камера, пол лежит плашмя, персонажи — вертикальные спрайты-биллборды
(как в Isaac). Сборка — **Bun**.
## Quick Start
Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
Двери видны всегда (закрыты в бою, открыты после зачистки). Графика — PNG-ассеты в
`src/assets/` (грузятся в `render/assets.ts`; при отсутствии файла — процедурный
фолбэк). Бриф на художку — `docs/ASSET_BRIEF.md`.
> Это рабочая **база для развития**, а не готовая игра. Архитектура специально
> сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам.
> Перед доработкой прочитай [`CLAUDE.md`](./CLAUDE.md) и [`docs/HOWTO.md`](./docs/HOWTO.md).
## Быстрый старт
```bash
bun install # install deps (none currently required)
bun start # production build → dist/
open dist/index.html
bun install # поставить зависимости (three, typescript)
bun run dev # дев-сервер с авто-пересборкой → http://localhost:3000
```
Or for development with live reload:
Продакшн-сборка:
```bash
bun run dev # starts dev server at http://localhost:3000 + watch mode
bun run build # минифицированный бандл → dist/ (открой dist/index.html)
```
## Controls
Проверки (запускай перед любым коммитом):
| Key | Action |
```bash
bun run typecheck # tsc --noEmit, строгий режим
bun test # юнит-тесты ядра
bun run check # и то, и другое разом
```
## Управление
| Клавиша | Действие |
|---|---|
| WASD | Move |
| Arrow keys | Attack in direction |
| Space | Attack in facing direction |
| Tab / Q | Switch weapon (Ranged ↔ Melee) |
| R | Restart (on Game Over / Victory) |
| WASD | движение |
| Стрелки | прицельная стрельба/удар в направлении |
| Пробел | атака по ходу движения |
| Tab / Q | сменить оружие (Дальний ↔ Ближний) |
| R | заново (на экране Game Over / Victory) |
| Esc | вернуться в меню выбора уровня |
## Weapons
Клавиши привязаны к **физическим** кнопкам (`event.code`), поэтому WASD/Q/R работают
в любой раскладке (в т.ч. русской), независимо от языка ввода.
| Mode | Weapon | Speed | Damage | Effect |
Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на
дверь, нажимая в её сторону.
## Режимы боя
| Режим | Оружие | Скорость | Урон | Особенность |
|---|---|---|---|---|
| Ranged | Pistol | Fast (10cd) | 1 per shot | Ranged projectiles |
| Melee | Knife | Slow (22cd) | 2 + knockback | Wide swing arc |
| Дальний | пистолет | быстро (cd 10) | 1 за выстрел | снаряды летят по прямой |
| Ближний | нож | медленно (cd 22) | 2 + отбрасывание | широкий взмах |
## Room Types
## Типы комнат
| Type | Description |
| Тип | Описание |
|---|---|
| Spawn | Starting room, no enemies |
| Normal | 24 enemies |
| Treasure | No enemies, loot room |
| Boss | 1 boss enemy, clearing wins the game |
| spawn | старт, врагов нет |
| normal | 24 врага |
| treasure | без врагов (комната-награда), сразу открыта |
| boss | 1 босс; его зачистка = победа |
## Project Structure
## Стек и устройство
- **Bun** — рантайм, бандлер и тест-раннер.
- **three.js** — WebGL-рендер мира в псевдо-3D (наклонная `PerspectiveCamera`, биллборд-спрайты).
- **TypeScript (strict)** — весь код.
- Архитектура: **логика игры полностью отделена от рендера**. Ядро (`src/core/`)
не знает ни про DOM, ни про three.js, поэтому его легко тестировать и при
желании можно подменить рендер, не трогая игру.
Подробности — в [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).
## Структура проекта
```
src/
├── main.ts Entry point
├── constants.ts Grid, canvas, tile/door constants
├── types.ts Shared TypeScript types
├── math.ts Utility: shuffle, rand, dist, overlap
├── input.ts Keyboard state (global KEYS map)
├── doors.ts Door geometry helpers
├── room/
│ ├── Room.ts Room class (tiles, enemies, tears)
│ ├── RoomMap.ts Map generation (connected 7×7 grid)
── tiles.ts Tile builders (wall/floor/door placement)
├── entities/
│ ├── Player.ts Player stat block
│ ├── Enemy.ts Enemy stat block + enemy type logic
── Tear.ts Ranged projectile
│ └── MeleeSwing.ts Melee hitbox
├── game/
│ ├── Game.ts Main orchestrator (loop, tick, render)
│ ├── collision.ts Wall collision (isBlocked, collidesWall)
│ ├── transitions.ts Room transition detection
│ └── spawner.ts Enemy placement logic
── render/
── roomRenderer.ts Tile rendering
├── entityRenderer.ts Player, enemy, tear, melee drawing
── hudRenderer.ts HP bar, mode indicator, HUD
└── minimapRenderer.ts Minimap drawing
├── config.ts ⭐ КОНСТАНТЫ движка: размеры, геометрия дверей, базовый баланс
├── main.ts точка входа: меню → игра, «склейка» логики/рендера/ввода
├── core/ ── ИГРОВАЯ ЛОГИКА (без DOM и three.js) ──
│ ├── Game.ts «мозг»: состояние + один шаг симуляции step()
│ ├── rules.ts ⭐ ПРАВИЛА УРОВНЯ (пресеты для меню: размер, враги, HP, seed)
│ ├── types.ts общие типы (Dir, RoomType, Box, …)
├── rng.ts ГПСЧ с seed (детерминизм для тестов/отладки)
│ ├── util.ts мат-утилиты (dist, overlap, clamp, lerp)
│ ├── entities/ Player, Enemy, Projectile, MeleeSwing
── world/ Room, RoomMap (генерация), tiles
│ └── systems/ collision, spawner (чистые функции)
├── input/ ── ВВОД ──
│ ├── InputState.ts абстрактные «намерения» (не сырые клавиши)
── KeyboardController.ts клавиатура → InputState
├── render/ ── РЕНДЕР (читает состояние, рисует) ──
│ ├── Renderer.ts интерфейс рендера
│ ├── ThreeRenderer.ts мир на three.js (псевдо-3D: наклон + биллборды)
│ ├── assets.ts ⭐ АССЕТЫ (процедурные спрайты/текстуры → текстуры three.js)
│ ├── theme.ts цвета/тинты мира (фон, эффекты)
│ └── HudOverlay.ts HUD и миникарта на 2D-канвасе поверх
── ui/
── StartMenu.ts стартовое меню (DOM) с выбором уровня
└── engine/
── GameLoop.ts игровой цикл с фиксированным шагом (60 Гц)
tests/ юнит-тесты ядра (bun test)
docs/ARCHITECTURE.md архитектура и потоки данных
CLAUDE.md правила для ИИ-агентов и разработчика
docs/HOWTO.md рецепты: как добавить врага/оружие/комнату
```
## Build System
- **Bun** — runtime + bundler
- `bun build` — bundles `src/main.ts``dist/main.js`
- `build.ts` — production build with minification + HTML copy
- No external libraries required (pure Canvas 2D)
## Architecture
- **Game** owns the game loop (`requestAnimationFrame`), the world
(`RoomMap`, `Player`), and dispatches to render functions.
- **Tick** processes input → movement → attack → enemy AI → room
clear check → transitions → win condition.
- **Transitions** check player tile position + keypress against door
geometry; call `Game.enterRoom()` which resets room state.
- **Collision** allows bounding-box overlap at door openings (row/col
outside normal bounds).
- **Rendering** is split into 4 pure functions that receive `ctx` + data.
+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` из свежего клона).
+22 -25
View File
@@ -1,35 +1,32 @@
import { build } from "bun";
/**
* Продакшн-сборка: минифицированный бандл src/main.ts → dist/main.js
* плюс копия index.html. Открывай dist/index.html.
*/
import { build } from 'bun';
import { cp, mkdir } from 'node:fs/promises';
const result = await build({
entrypoints: ["./src/main.ts"],
outdir: "./dist",
target: "browser",
entrypoints: ['./src/main.ts'],
outdir: './dist',
target: 'browser',
minify: true,
sourcemap: "external",
sourcemap: 'external',
});
if (!result.success) {
console.error("Build failed", result.logs);
console.error('Сборка упала:');
for (const log of result.logs) console.error(log);
process.exit(1);
}
// Copy HTML template with correct script reference
const html = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Dungeon Crawl — Ranged / Melee</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
</style>
</head>
<body>
<canvas id="game" width="880" height="660"></canvas>
<script src="main.js"></script>
</body>
</html>`;
await Bun.write('./dist/index.html', await Bun.file('./index.html').text());
Bun.write("./dist/index.html", html);
console.log("Build complete → dist/");
// Копируем картинки в 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)');
+35
View File
@@ -0,0 +1,35 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "dungeon-crawl",
"dependencies": {
"three": "^0.184.0",
},
"devDependencies": {
"@types/three": "^0.184.1",
"typescript": "^6.0.3",
},
},
},
"packages": {
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
"@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="],
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
"three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
}
}
+28 -35
View File
@@ -1,50 +1,43 @@
import { spawn } from "bun";
/**
* Дев-сервер: бандлит src/main.ts → dist/main.js в watch-режиме и раздаёт
* dist на http://localhost:3000. index.html копируется из корня (единый
* источник правды) — правь его там, не в dist.
*/
import { spawn } from 'bun';
// Ensure dist/ has the HTML shell
await Bun.write(
"./dist/index.html",
`<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dungeon Crawl — Ranged / Melee</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
</style>
</head>
<body>
<canvas id="game" width="880" height="660"></canvas>
<script src="main.js"></script>
</body>
</html>`
const builder = spawn(
// --sourcemap=linked: в DevTools брейкпоинты/стек указывают на твой src/*.ts,
// а не на собранный main.js. .map отдаётся из dist тем же сервером.
['bun', 'build', 'src/main.ts', '--outdir', 'dist', '--target', 'browser', '--sourcemap=linked', '--watch'],
{ stdout: 'inherit', stderr: 'inherit' },
);
// Start build watcher
const builder = spawn(["bun", "build", "src/main.ts", "--outdir", "dist", "--target", "browser", "--watch"], {
stdout: "inherit",
stderr: "inherit",
});
// Start dev server
const port = 3000;
const server = Bun.serve({
port,
async fetch(req) {
const url = new URL(req.url);
let path = url.pathname === "/" ? "/index.html" : url.pathname;
const file = Bun.file("./dist" + path);
return new Response(file);
// index.html отдаём прямо из корня — правки CSS/разметки сразу живые;
// бандл main.js и карты — из dist (их пишет watch-сборщик).
if (url.pathname === '/' || url.pathname === '/index.html') {
return new Response(Bun.file('./index.html'));
}
// Картинки отдаём прямо из src/assets/ — положил PNG → сразу подхватился (без пересборки).
if (url.pathname.startsWith('/assets/')) {
const asset = Bun.file('./src' + url.pathname);
if (await asset.exists()) return new Response(asset);
return new Response('Not found', { status: 404 });
}
const file = Bun.file('./dist' + url.pathname);
if (await file.exists()) return new Response(file);
return new Response('Not found', { status: 404 });
},
});
console.log(`\n Dev server: http://localhost:${port}`);
console.log(` Watching: src/\n`);
console.log(`\n Dev server: http://localhost:${server.port}`);
console.log(' Watching: src/\n');
// Graceful shutdown
process.on("SIGINT", () => {
process.on('SIGINT', () => {
builder.kill();
server.stop();
process.exit(0);
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Dungeon Crawl — Ranged / Melee</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
</style>
</head>
<body>
<canvas id="game" width="880" height="660"></canvas>
<script src="main.js"></script>
</body>
</html>
-1091
View File
File diff suppressed because it is too large Load Diff
+174 -102
View File
@@ -1,131 +1,203 @@
# Architecture
# Архитектура
## Overview
The game is a single-page Canvas 2D application. The source is written
in TypeScript and split into ~19 modules under `src/`, bundled by Bun
into a single `dist/main.js`. The HTML shell in `dist/index.html` loads
the bundle.
## Module Dependency Graph
## Главный принцип: логика отдельно от рендера
```
main.ts
├── input.ts (global KEYS, setupInput)
└── Game.ts
├── constants.ts (all shared numeric constants)
├── types.ts (Dir, RoomType, CombatMode, Box, Doors)
├── math.ts (shuffle, rand, ri, dist, overlap)
├── RoomMap.ts → Room.ts, tiles.ts, doors.ts
├── Player.ts
├── Enemy.ts
├── Tear.ts
├── MeleeSwing.ts
├── collision.ts → Room.ts, tiles.ts
├── transitions.ts → Game.ts (type only), input.ts
├── spawner.ts → Room.ts, Enemy.ts
└── render/*
→ roomRenderer.ts, entityRenderer.ts,
hudRenderer.ts, minimapRenderer.ts
┌──────────────────────────────────────────┐
│ GameLoop (engine/) │
│ фиксированный шаг 60 Гц + интерполяция │
└───────┬───────────────┬──────────────┬─────┘
│ poll() │ step(input) │ render(game, alpha)
▼ ▼ ▼
KeyboardController Game (core/) Renderer (render/)
клавиши → InputState состояние + ЧИТАЕТ состояние,
логика игры рисует three.js + HUD
```
## Game Loop
Три части не знают лишнего друг о друге:
Every frame (`requestAnimationFrame`):
- **`core/`** — игровая логика. Не импортирует ни `three`, ни DOM (`window`,
`document`, `canvas`). Получает на вход абстрактный `InputState`, меняет своё
состояние. Поэтому ядро **тестируется без браузера** (`bun test`) и не зависит
от того, чем мы рисуем.
- **`render/`** — рендер. Только **читает** `Game` и рисует. Менять состояние
игры рендеру запрещено.
- **`input/`** — превращает устройство ввода (клавиатуру) в абстрактные
«намерения» (`InputState`). Захочешь геймпад — добавишь ещё один контроллер.
1. **Timers** — decrement `invTimer`, `atkCD`, `transCD`
2. **Movement** — read WASD, apply velocity, resolve wall collisions
3. **Attack** — read arrow keys / Space, fire tear or create melee swing
4. **Melee Update** — decrement swing life, apply damage + knockback
5. **Tear Update** — move projectiles, check wall/enemy collisions
6. **Enemy AI** — chase player, contact damage with cooldown
7. **Room Clear** — if all enemies dead → `room.cleared = true`, rebuild tiles with doors
8. **Transition Check** — if player on door tile + key pressed → load adjacent room
9. **Win Check** — if boss room cleared → `won = true`
10. **Render** — draw room tiles → entities → HUD → minimap → overlays
`main.ts` — единственное место, где всё это соединяется.
## Transition System
> **Почему так строго.** Этот проект уже один раз сломался, когда логику,
> рендер и состояние перемешали и расползлись по модулям. Граница «логика ↔
> рендер» — то, что не даёт повторить ту историю. Не протаскивай `three` в
> `core/` и не лезь в игровое состояние из рендера.
Room transitions are the most complex subsystem. Key design:
## Игровой цикл с фиксированным шагом (engine/GameLoop.ts)
- **Door geometry**: `DOOR` constant defines 3-tile-wide openings at
each cardinal edge (top: cols 6-8, row 0; bottom: cols 6-8, row 10;
left: rows 4-6, col 0; right: rows 4-6, col 14).
- **Detection**: `checkTransition()` computes the player's tile position
(`col, row`). If they're on a door tile AND pressing the matching
movement key, the transition fires.
- **Cooldown**: `transCD = 15` prevents re-entry within 15 frames.
- **Movement lock**: Transition only works in cleared rooms
(`room.cleared === true`).
- **Collision bypass**: `isBlocked()` returns `false` for out-of-bounds
tiles at door openings, letting the player's bounding box extend
beyond the room boundary.
Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
## Collision System
1. Раз в кадр опрашиваем источник ввода (`controller.poll()`). Цикл знает только
маленький интерфейс `poll(): InputState`, а не конкретную клавиатуру.
2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`.
3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными
порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
4. Рисуем с коэффициентом интерполяции `alpha` (доля до следующего шага), чтобы
движение было плавным и на 144 Гц.
- `isBlocked(room, col, row)` — per-tile check. Returns `true` for
wall tiles and out-of-bounds positions, except at door openings.
- `collidesWall(box, room, ox, oy)` — iterates all tiles covered by
the entity's bounding box. If any tile is blocked, returns `true`.
- Movement resolves axis-independently: apply X, revert if collision;
apply Y, revert if collision.
Единица времени везде — **шаг** (= 1/60 c). Скорости заданы «пикселей за шаг»,
перезарядки — «в шагах».
## Room Generation
## Шаг симуляции (core/Game.ts → step)
`RoomMap.generate()` uses a random walk:
Каждый шаг по порядку:
1. Start at (0,0) with a spawn room.
2. Maintain a frontier list of rooms that have room to expand.
3. Each step, pick a random frontier room, shuffle directions, and
attempt to add a new room in an unoccupied adjacent cell within
the 7×7 grid.
4. Room types are assigned probabilistically (boss at ~20%, treasure
at ~12%, rest normal).
5. If no boss room was generated, one normal room is promoted.
1. **Снимок prev-позиций**`prevX/prevY` всех подвижных сущностей (для интерполяции).
2. **Таймеры**`invTimer`, `atkCD`, `transCD`.
3. **Движение** игрока (WASD), коллизии со стенами по осям раздельно (скольжение).
4. **Атака** — стрелки/пробел: выстрел (`Projectile`) или взмах (`MeleeSwing`).
5. **Ближний бой** — урон+отбрасывание по врагам в хитбоксе взмаха.
6. **Снаряды** — полёт, попадание в стену/врага.
7. **ИИ врагов** — преследование игрока, контактный урон с перезарядкой.
8. **Зачистка** — если врагов было >0 и все мертвы → `cleared=true`, открыть двери.
9. **Переход** — стоя на двери и нажимая в её сторону → соседняя комната.
10. **Победа** — если комната-босс зачищена → `won=true`.
## Rendering
Смерть (`hp<=0`) ставит `gameOver=true`; пока `gameOver`/`won``step()` ничего не делает.
Rendering is split into 4 stateless functions, each taking
`CanvasRenderingContext2D` as the first argument:
## Ввод (input/)
- **roomRenderer** — tile loop with wall/door/floor styles
- **entityRenderer** — enemies (3 types), player (body + weapon),
tears, melee swing arc
- **hudRenderer** — HP bar, mode indicator, enemy count, room label
- **minimapRenderer** — 7×7 grid with visited/current room highlights
`InputState` — снимок намерений: оси движения, направление прицела, флаги
удержания и однократные «edge»-действия. Делится на:
## Weapon Visuals
- **удерживаемые** (`moveX/Y`, `aimDir`, `attackHeld`) — читаются каждый шаг;
- **однократные** (`toggleWeapon`, `restart`) — срабатывают один раз на нажатие;
поэтому они обрабатываются в `consumeActions()` раз в кадр, а не в `step()`.
- **Pistol (ranged)**: Barrel line + body rect, rotated toward facing
direction. Muzzle flash circle at `atkCD > 8`.
- **Knife (melee)**: Triangular blade + handle + guard rects, offset
in facing direction.
## Мир: комнаты и генерация (core/world/)
## Enemy Types
- **Комната** — сетка `COLS×ROWS` тайлов (стены по краю, пол внутри). Двери
прорезаются в тайлах только когда комната `cleared` (или это `spawn`).
- **RoomMap** — связный набор комнат на сетке `(2·MAP_RADIUS+1)²`, генерируется
**случайным блужданием прямо в конструкторе**. Двери ставятся парами: у текущей
комнаты в сторону соседа и встречная у соседа (через `OPP`).
| Type | Size | HP | Speed | Damage | Visual |
|--------|------|----|-------|--------|--------|
| Normal | 32px | 3 | 1.15 | 1 | Brown block, yellow eyes |
| Fast | 26px | 2 | 1.9 | 1 | Red circle, small eyes |
| Boss | 46px | 10 | 0.9 | 2 | Large red circle, horns, HP bar |
> **Исторический баг №1.** При разнесении на модули у `RoomMap` потеряли вызов
> генерации → карта была пустой → игра падала на старте. Теперь генерация в
> конструкторе, а тест `tests/roomMap.test.ts` это стережёт.
>
> **Исторический баг №2.** `OPP` была `{up:'bottom', down:'top'}` (несуществующие
> ключи дверей) — встречные двери не ставились. Сейчас `{up:'down', down:'up'}`,
> симметрия дверей покрыта тестом.
## State Management
## Коллизии (core/systems/collision.ts)
- `GAME_OVER` / `WON` — boolean flags checked in loop and render.
- `KEYS` — global mutable map, reset on window blur.
- Room state (`cleared`, `visited`, `enemies[]`, `tears[]`)per-room.
- Player state (`hp`, `mode`, `facing`, `transCD`, etc.) — single
`Player` instance.
- Cooldowns (`invTimer`, `atkCD`, `transCD`) decrement each frame.
- `isBlocked(room, col, row)` — тайл-стена или выход за границы блокируют, **кроме**
дверных проёмов (там граница «прозрачна», чтобы встать на дверь и перейти).
- `collidesWall(box, room)`перебирает тайлы под хитбоксом.
- Движение разрешается по осям раздельно: применяем X (откат при коллизии),
затем Y. Это даёт «скольжение» вдоль стен.
## Build Pipeline
## Рендер (render/)
Два наложенных холста (см. `index.html`):
- **`#game`** — WebGL, мир рисует `ThreeRenderer`.
- **`#hud`** — 2D-канвас поверх, `HudOverlay` рисует HP, индикатор режима,
счётчик врагов, подпись комнаты, миникарту и оверлеи Game Over/Victory.
### ThreeRenderer — псевдо-3D (наклонный вид, как в Isaac)
Логика остаётся 2D-сверху, но РЕНДЕР — псевдо-3D:
- **Карта координат:** игровая точка `(x, y)` кладётся на пол в 3D как `(x, 0, y)`
— пол это плоскость `Y=0`, вверх это `+Y`, «низ» игры (`y`) идёт в глубину (`Z`).
Поэтому вся математика и **коллизии ядра без изменений** — псевдо-3D чисто визуальный.
- **Камера** — `PerspectiveCamera`, наклонная и фиксированная на комнату (приподнята и
отодвинута на «юг», смотрит вниз-вперёд ≈50°). Кадрирует комнату целиком.
- **Пол** — одна горизонтальная плоскость с бесшовной текстурой (повтор по сетке).
⚠️ Плоскость кладём ПЛАШМЯ через `flatMesh()` (поворот −90° вокруг X). Если забыть
поворот — пол встанет вертикально и перспектива «вывернется» (был такой баг).
- **Стены** — вертикальные плоскости по периметру с проёмами под двери.
- **Персонажи/враги/двери/снаряды** — ВЕРТИКАЛЬНЫЕ спрайты-биллборды (плоскости в XY,
нормаль +Z), стоящие на полу: центр на `y = высота/2`, низ на полу. Текстуры — из
`assets.ts` (см. ниже). Материалы — `DoubleSide` (наша камера переворачивает Y, иначе
грани отсеклись бы) + `alphaTest` для чёткого контура спрайта.
- **Двери всегда видимы:** рисуются из `room.doors` независимо от зачистки — закрытые
(засов) пока `!cleared`, открытый проём после. Группа комнаты пересобирается при смене
комнаты ИЛИ смене флага `cleared`.
- **Тени** — отдельный плоский спрайт-«пятно» под каждой сущностью.
### Ассеты (render/assets.ts)
`Assets` грузит текстуры из `src/assets/<ключ>.png` (спрайты персонажей, пол, стены,
двери, снаряд, эффекты, тень) и кэширует их; если PNG нет — рисует процедурный фолбэк
на `<canvas>`, поэтому игра не ломается без ассетов. Dev-сервер отдаёт `/assets/*` из
`src/assets/`, прод-сборка копирует их в `dist/assets/`. Подмена/добавление графики —
`docs/HOWTO.md` и `docs/ASSET_BRIEF.md`. Ключи ассетов едины в рендере, брифе и именах файлов.
### Эффекты оружия
Рендер сам распознаёт события по состоянию (ядро не трогаем):
- вспышка из дула — когда `player.atkCD` «подскочил» (выстрел);
- искра — когда у врага вырос `hitTimer` (попадание);
- облачко-«пуф» — когда мёртвый враг исчезает из комнаты.
Эффекты — короткоживущие аддитивные спрайты-биллборды, гаснут по таймеру.
### Управление ресурсами GPU (важно — иначе утечки)
- Общие геометрии переиспользуются масштабированием — не плодим геометрии.
- Группа комнаты (пол/стены/двери) пересобирается только при смене комнаты/`cleared`;
персональные материалы дверей `dispose()`-ятся при пересборке.
- Спрайты сущностей и эффекты создаются/удаляются по факту появления/исчезновения
(mark-and-sweep), их персональные материалы корректно `dispose()`-ятся.
- Общие материалы/геометрии и `Assets` освобождаются один раз в `dispose()`.
## HiDPI
`ThreeRenderer` и `HudOverlay` увеличивают буфер под `devicePixelRatio` (до 2×),
а рисуют в логических координатах `CW×CH`. CSS-размер холстов фиксирован
(`width/height: 100%` внутри `#stage` 880×660) — без этого canvas как
replaced-элемент показался бы в размер HiDPI-буфера и вылез бы за рамки.
## Детерминизм
Вся случайность идёт через один экземпляр `Rng` (seed). Один и тот же seed даёт
одну и ту же карту/спавн — удобно для отладки и обязательно для тестов. Никаких
`Math.random()` в `core/` — только `rng`.
## Меню, правила уровня и кастомные ассеты
### Поток запуска
`main.ts` показывает стартовое меню (`ui/StartMenu.ts`, DOM поверх холстов). По
клику на уровень создаётся `Game(rules)` и запускается `GameLoop`. Esc во время
игры останавливает цикл и снова показывает меню (фон меню перекрывает последний
кадр). Рендер (`ThreeRenderer`, `HudOverlay`) и ввод создаются один раз и
переиспользуются между забегами; на каждый забег пересоздаётся только `Game` и
`GameLoop`. Рендер сам подхватывает смену игры: новая комната ≠ отрисованной →
геометрия комнаты пересобирается, меши старых сущностей убираются mark-and-sweep.
### Правила уровня (core/rules.ts)
`LevelRules` — данные, параметризующие забег: размер карты, плотность/сила врагов,
HP/скорость игрока, опциональный фиксированный seed. `PRESETS` — список для меню
(добавил объект → пункт появился). Правила протекают так:
```
bun build src/main.ts --outdir dist --target browser --minify
cp src/index.html dist/
LevelRules ──► Game(rules) ──► RoomMap(rng, rules) // размер карты
└──► Player(rules.player) // HP/скорость
└──► spawnEnemies(..., rules) // число/тип/сила врагов (множители)
```
- Bundler: Bun's native bundler (esbuild under the hood).
- Target: browser (ES module → IIFE/global wrapper).
- Result: single `dist/main.js` (~30 KB) + `dist/index.html`.
- Open `dist/index.html` directly in any modern browser.
Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в
`config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`.
`config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов).
### Тема / ассеты (render/theme.ts)
Внешний вид мира вынесен в `Theme` (сейчас — только цвета примитивов). `ThreeRenderer`
берёт цвета из темы, а не из хардкода, поэтому вид легко подменить, не трогая логику.
Это **задел под кастомные ассеты**: чтобы перейти на спрайты/текстуры, расширь `Theme`
полями с путями к изображениям, загрузи их `THREE.TextureLoader` и положи в
`material.map`. Логика игры при этом не меняется.
+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/` — подхватится само.
+176
View File
@@ -0,0 +1,176 @@
# HOWTO — рецепты доработки
Практические инструкции «как сделать X». Каждый рецепт перечисляет все файлы,
которые нужно тронуть. После любой правки — `bun run check` и проверка в браузере
(`bun run dev`). Общие правила — в [`CLAUDE.md`](../CLAUDE.md).
---
## 1. Покрутить баланс (скорость, HP, урон, число врагов)
Всё в одном файле — **`src/config.ts`**. Например:
- игрок быстрее: `PLAYER.speed`;
- больше HP у босса: `ENEMY_STATS.boss.hp`;
- больше врагов в комнате: `SPAWN.normalMin` / `SPAWN.normalExtra`;
- крупнее данжен: `MIN_ROOMS` / `EXTRA_ROOMS` / `MAP_RADIUS`.
Код менять не нужно. Это и есть смысл `config.ts`.
---
## 2. Добавить новый тип врага (например, `tank`)
1. **`src/core/types.ts`** — добавь в union: `export type EnemyType = 'normal' | 'fast' | 'boss' | 'tank';`
2. **`src/config.ts`** — строка характеристик в `ENEMY_STATS`:
```ts
tank: { size: 40, hp: 8, speed: 0.7, damage: 2 },
```
3. **`src/core/systems/spawner.ts`** — реши, когда он спавнится (логика выбора типа
в начале цикла). Напр. с шансом: `rng.chance(0.15) ? 'tank' : rng.chance(ENEMY.fastChance) ? 'fast' : 'normal'`.
4. **`src/render/assets.ts`** — добавь ключ в `SpriteKey` и ветку в `Assets.sprite()`
(рисуется через `drawCharacter(...)` с твоими цветами).
5. **`src/render/ThreeRenderer.ts`** — добавь строку в `enemyMatKey`, сопоставив новый
`Enemy['type']` этому `SpriteKey` (без этого не пройдёт проверка типов).
ИИ, урон, отбрасывание, мигание при попадании — общие, их трогать не нужно.
Добавь тест в `tests/spawner.test.ts`, если ввёл особое правило спавна.
---
## 3. Добавить новое поведение врага (особый ИИ)
Сейчас все враги ведут себя одинаково (преследование игрока) в
`Game.updateEnemies` (`src/core/Game.ts`). Чтобы развести поведение:
- по `e.type` внутри `updateEnemies` развилкой (просто, для 2–3 типов), **или**
- вынеси ИИ в `src/core/systems/ai.ts` как функции `update<Type>(enemy, player, room)`
и диспетчеризуй по типу (чище, когда типов много).
Держи это в `core/` (без рендера) и старайся писать чистыми функциями — их легко
покрыть тестом.
---
## 4. Добавить оружие / третий режим боя
Режимы — это `MODE_RANGED = 0` / `MODE_MELEE = 1` и тип `CombatMode = 0 | 1`.
Для третьего режима:
1. **`src/config.ts`** — константа `MODE_X = 2` и блок баланса.
2. **`src/core/types.ts`** — расширь `CombatMode` (`0 | 1 | 2`).
3. **`src/core/Game.ts`** — в `handleAttack` добавь ветку создания нужного снаряда/
эффекта; в `consumeActions` смена оружия циклом по всем режимам.
4. **`src/render/HudOverlay.ts`** — подпись/иконка режима.
5. Если это новый вид снаряда — заведи сущность в `src/core/entities/` и обновляй
её в `Game.step` (по образцу `Projectile`/`updateTears`), а в `ThreeRenderer`
добавь её отрисовку.
---
## 5. Добавить тип комнаты (например, `shop`)
1. **`src/core/types.ts`** — добавь в `RoomType`.
2. **`src/core/world/RoomMap.ts`** — правило назначения типа в `generate()`.
3. **`src/core/systems/spawner.ts`** — сколько врагов (часто 0).
4. **`src/core/Game.ts`** — если врагов 0, комната уже авто-зачищается при входе
(см. `enterRoom`); особая логика комнаты — здесь же.
5. **`src/render/HudOverlay.ts`** — цвет на миникарте и подпись (`drawMinimap`/`drawHud`).
---
## 6. Поменять управление
**`src/input/KeyboardController.ts`** — метод `poll()` (маппинг клавиш на
`InputState`) и набор `PREVENT` (клавиши, у которых гасим поведение браузера).
Логику игры это не затрагивает — она читает только `InputState`.
Геймпад/тач: сделай новый контроллер с тем же `poll(): InputState` и подставь его
в `main.ts`.
---
## 7. Добавить уровень (пресет правил) в меню
**`src/core/rules.ts`** — добавь объект в массив `PRESETS`. Он сразу появится
кнопкой в стартовом меню (меню строится из `PRESETS`). Например:
```ts
{
id: 'swarm',
name: 'Орда',
description: 'Очень много слабых быстрых врагов.',
map: { minRooms: 10, extraRooms: 4, mapRadius: 4 },
player: { maxHp: 6, speed: 3.2 },
enemies: { densityMul: 2.5, fastChance: 0.9, hpMul: 0.5, speedMul: 1.2, bossHpMul: 1 },
},
```
- `seed` (необязательный) фиксирует данжен — одинаковый забег каждый раз.
- Нужен новый «рычаг» (например, шанс сокровищниц)? Добавь поле в `LevelRules` и
читай его там, где раньше брал константу из `config.ts` (генерация — `RoomMap`,
спавн — `spawner`). Тест на влияние правил — в `tests/game.test.ts`.
---
## 8. Кастомные ассеты (спрайты, текстуры, тема)
Графика грузится из PNG в **`src/assets/`** (класс `Assets` в `src/render/assets.ts`
тянет `assets/<ключ>.png` через `THREE.TextureLoader`); если файла нет — рисуется
процедурный фолбэк на canvas, и игра не ломается.
- **Свой/новый ассет:** просто положи PNG с именем `<ключ>.png` в **`src/assets/`**
(dev-сервер отдаёт их сразу, прод-сборка копирует в `dist/assets`). Код менять не нужно.
Ключи = именам файлов (`player-ranged`, `enemy-boss`, `floor`, `door-closed`, `tear`,
`heart-full`, `icon-ranged`, …); полный список и промпт для дизайн-ИИ — в **`docs/ASSET_BRIEF.md`**.
- **Запасной рисунок (фолбэк):** функции `drawX()` в `assets.ts` — правь их, только если
нужен другой плейсхолдер на случай отсутствия PNG.
- **Цвета/тинты/фон** (не текстуры) — в **`src/render/theme.ts`** (`Theme` + `DEFAULT_THEME`).
- **HUD-ассеты** (сердечки `heart-*`, иконки `icon-*`) грузит `HudOverlay`; меню (`logo`,
`menu-bg`) — `index.html`. Те же имена в `src/assets/`.
- **Своя палитра:** сделай ещё один объект `Theme` и передай его в
`new ThreeRenderer(canvas, myTheme)` в `main.ts`. Сейчас рендер создаётся один
раз при старте с темой по умолчанию и переиспользуется между забегами, поэтому
чтобы **выбирать тему по правилам уровня**, нужно либо перенести
`new ThreeRenderer(...)` внутрь `startGame(rules)` (и `dispose()` предыдущий),
либо добавить рендеру метод `setTheme(theme)`.
- **Перейти на спрайты/текстуры:** расширь `Theme` полями с путями к картинкам
(см. комментарий-задел в `theme.ts`), загрузи их через `THREE.TextureLoader` в
`ThreeRenderer` и положи текстуру в `material.map` соответствующих мешей вместо
(или вместе с) `color`. Логика игры при этом НЕ меняется — это чисто рендер.
---
## 9. Поменять или нарастить рендер (вплоть до 3D)
Рендер изолирован за интерфейсом **`src/render/Renderer.ts`** (`render(game, alpha)`
+ `dispose()`). Варианты:
- **Доработать вид** (спрайты, текстуры, частицы) — внутри `ThreeRenderer` (камера
уже наклонная `PerspectiveCamera`, сущности — биллборд-спрайты).
- **Усилить 3D** — для настоящего объёма замени `MeshBasicMaterial` (он без света)
на `MeshStandardMaterial`, добавь источники света и объёмные меши вместо плоскостей.
Мир рисуется по тем же координатам сущностей из `Game` — логику менять не нужно.
- **Другой рендер целиком** (например, Canvas2D для отладки) — реализуй `Renderer`
и подставь в `main.ts`. Ядро не трогается вообще.
Помни про управление ресурсами (правило 6 в `CLAUDE.md`).
---
## 10. Отладка
- В консоли браузера доступен `game` — текущий `Game`. Примеры:
```js
game.player.hp = 99 // бессмертие на тест
game.curRoom.enemies.length // сколько врагов в комнате
game.reset() // новая карта
```
- **Детерминизм:** чтобы воспроизвести конкретную карту, задай `seed` правилам —
проще всего поставить `seed: 42` нужному пресету в `core/rules.ts` (см. пресет
`daily`). Тот же seed — та же генерация и спавн, в т.ч. после рестарта.
В тестах можно передать свой ГПСЧ **вторым** аргументом: `new Game(rules, new Rng(42))`
(первый аргумент — правила, не ГПСЧ).
- **Тесты ядра** гоняются мгновенно и без браузера: `bun test`. Логику отлаживай
тестом, а не кликами.
+61 -694
View File
@@ -1,702 +1,69 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Dungeon Crawl — Ranged / Melee</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
</style>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Биндим Фигняшку</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: monospace;
color: #ccc;
overflow: hidden;
user-select: none;
}
/* Сцена = два наложенных холста: WebGL-мир снизу, 2D-HUD сверху.
width/height: 100% обязательны — иначе canvas (replaced-элемент)
показывается в размер своего HiDPI-буфера и вылезает за рамки. */
#stage { position: relative; width: 880px; height: 660px; border: 1px solid #222; }
#stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#hud { pointer-events: none; }
/* Стартовое меню — поверх холстов, перекрывает сцену своим фоном. */
#menu {
position: absolute; inset: 0; z-index: 10;
/* фон-картинка с тёмным затемнением для читаемости; фолбэк — тёмный цвет */
background: linear-gradient(rgba(10,10,15,0.7), rgba(10,10,15,0.82)),
url(assets/menu-bg.png) center / cover no-repeat, #0a0a0f;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 14px; padding: 24px;
}
#menu h1 { font-size: 40px; letter-spacing: 4px; color: #c9b27a; margin-bottom: 4px; }
#menu-logo { display: block; width: min(560px, 80%); height: auto; }
.menu-sub { color: #888; margin-bottom: 6px; }
#menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; }
.menu-preset {
display: flex; flex-direction: column; gap: 3px;
text-align: left; cursor: pointer;
background: #15151f; color: #ddd;
border: 1px solid #333; border-radius: 4px;
padding: 12px 14px; font-family: monospace; font-size: 14px;
transition: background .12s, border-color .12s;
}
.menu-preset:hover { background: #1d1d2c; border-color: #5a78c0; }
.menu-preset-name { font-weight: bold; color: #cdd6f4; }
.menu-preset-desc { font-size: 11px; color: #888; }
.menu-hint { color: #555; font-size: 12px; margin-top: 10px; }
</style>
</head>
<body>
<canvas id="game" width="880" height="660"></canvas>
<script>
// ============================================================
// CONSTANTS
// ============================================================
const CW=880,CH=660,TILE=44,COLS=15,ROWS=11;
const RW=COLS*TILE,RH=ROWS*TILE;
const OX=(CW-RW)/2,OY=80;
<div id="stage">
<canvas id="game" width="880" height="660"></canvas>
<canvas id="hud" width="880" height="660"></canvas>
const T_WALL=0,T_FLOOR=1,T_DOOR=2;
const MODE_RANGED=0,MODE_MELEE=1;
const DIR={
up:[0,-1], down:[0,1], left:[-1,0], right:[1,0]
};
const OPP={up:'bottom',down:'top',left:'right',right:'left'};
const DOOR={
up: { cols:[6,7,8], row:0, cx:7, cy:0 },
down: { cols:[6,7,8], row:10, cx:7, cy:10 },
left: { col:0, rows:[4,5,6], cx:0, cy:5 },
right:{ col:14, rows:[4,5,6], cx:14,cy:5 }
};
let KEYS={},GAME_OVER=false,WON=false;
// ============================================================
// UTILITY
// ============================================================
function shuffle(a){for(let i=a.length-1;i>0;i--){const j=Math.random()*i|0;[a[i],a[j]]=[a[j],a[i]]}return a}
function rand(a,b){return Math.random()*(b-a)+a}
function ri(a,b){return Math.floor(rand(a,b+1))}
function dist(x1,y1,x2,y2){return Math.hypot(x2-x1,y2-y1)}
function overlap(a,b){return a.x<b.x+b.w&&a.x+a.w>b.x&&a.y<b.y+b.h&&a.y+a.h>b.y}
// ============================================================
// ROOM
// ============================================================
class Room{
constructor(c,r,type){
this.c=c;this.r=r;this.type=type;
this.doors={up:false,down:false,left:false,right:false};
this.visited=false;this.cleared=false;
this.enemies=[];this.tears=[];
this.tiles=[];this.buildTiles();
}
buildTiles(){
for(let r=0;r<ROWS;r++){
this.tiles[r]=[];
for(let c=0;c<COLS;c++){
this.tiles[r][c]=(r===0||r===ROWS-1||c===0||c===COLS-1)?T_WALL:T_FLOOR;
}
}
if(this.cleared)this.placeDoors();
}
placeDoors(){
if(this.doors.up)for(const c of DOOR.up.cols)this.tiles[DOOR.up.row][c]=T_DOOR;
if(this.doors.down)for(const c of DOOR.down.cols)this.tiles[DOOR.down.row][c]=T_DOOR;
if(this.doors.left)for(const r of DOOR.left.rows)this.tiles[r][DOOR.left.col]=T_DOOR;
if(this.doors.right)for(const r of DOOR.right.rows)this.tiles[r][DOOR.right.col]=T_DOOR;
}
}
// ============================================================
// ROOM MAP
// ============================================================
class RoomMap{
constructor(){this.rooms={};this.generate()}
key(c,r){return c+','+r}
get(c,r){return this.rooms[this.key(c,r)]}
has(c,r){return !!this.get(c,r)}
add(c,r,t){const o=new Room(c,r,t);this.rooms[this.key(c,r)]=o;return o}
hasBoss(){return Object.values(this.rooms).some(r=>r.type==='boss')}
generate(){
this.add(0,0,'spawn');
let frontier=[[0,0]],count=1,target=8+ri(0,4);
const dirs=[['up',0,-1],['down',0,1],['left',-1,0],['right',1,0]];
while(frontier.length>0&&count<target){
const idx=ri(0,frontier.length-1);
const [cr,cc]=frontier[idx];
shuffle(dirs);
let added=false;
for(const [d,dc,dr] of dirs){
if(count>=target)break;
const nc=cr+dc,nr=cc+dr;
if(Math.abs(nc)>3||Math.abs(nr)>3||this.has(nc,nr))continue;
let type='normal';
if((count===target-1||(Math.random()<0.2&&count>=3))&&!this.hasBoss())type='boss';
else if(Math.random()<0.12&&count>=2)type='treasure';
this.add(nc,nr,type);
this.get(cr,cc).doors[d]=true;
this.get(nc,nr).doors[OPP[d]]=true;
frontier.push([nc,nr]);count++;added=true;
}
if(!added)frontier.splice(idx,1);
}
if(!this.hasBoss()){
const cs=Object.values(this.rooms).filter(r=>r.type==='normal');
if(cs.length>0)cs[ri(0,cs.length-1)].type='boss';
}
}
}
// ============================================================
// ENTITIES
// ============================================================
class Player{
constructor(){
this.x=0;this.y=0;this.w=26;this.h=26;
this.speed=3.2;this.hp=6;this.maxHp=6;
this.mode=MODE_RANGED;this.facing='up';this.moveDir='up';
this.atkCD=0;this.invTimer=0;this.transCD=0;
}
get box(){return{x:this.x-this.w/2,y:this.y-this.h/2,w:this.w,h:this.h}}
}
class Enemy{
constructor(x,y,type){
this.x=x;this.y=y;this.type=type;
this.w=type==='boss'?46:type==='fast'?26:32;
this.h=this.w;
this.hp=type==='boss'?10:type==='fast'?2:3;
this.maxHp=this.hp;
this.speed=type==='boss'?0.9:type==='fast'?1.9:1.15;
this.damage=type==='boss'?2:1;
this.knx=0;this.kny=0;this.hitTimer=0;this.atkTimer=0;
}
get box(){return{x:this.x-this.w/2,y:this.y-this.h/2,w:this.w,h:this.h}}
get alive(){return this.hp>0}
}
class Tear{
constructor(x,y,dx,dy){
this.x=x;this.y=y;this.dx=dx;this.dy=dy;
this.r=5;this.speed=7;this.damage=1;this.life=80;
}
get alive(){return this.life>0}
}
class MeleeSwing{
constructor(x,y,dir){
this.dir=dir;this.life=10;this.damage=2;this.kb=10;
const d=22,s=50;
switch(dir){
case'up': this.box={x:x-s/2,y:y-d-s,w:s,h:s};break;
case'down': this.box={x:x-s/2,y:y+d,w:s,h:s};break;
case'left': this.box={x:x-d-s,y:y-s/2,w:s,h:s};break;
case'right':this.box={x:x+d,y:y-s/2,w:s,h:s};break;
}
}
get alive(){return this.life>0}
}
// ============================================================
// GAME
// ============================================================
let game=null;
class Game{
constructor(){
this.canvas=document.getElementById('game');
this.ctx=this.canvas.getContext('2d');
this.map=new RoomMap();
this.player=new Player();
this.cc=0;this.cr=0;this.meleeSwing=null;
this.setupInput();
this.enterRoom('up');
this.update();
}
get cur(){return this.map.get(this.cc,this.cr)}
setupInput(){
window.addEventListener('keydown',e=>{
if((e.key==='Tab'||e.key==='q'||e.key==='Q')&&!GAME_OVER&&!WON){
e.preventDefault();this.player.mode=this.player.mode===MODE_RANGED?MODE_MELEE:MODE_RANGED;
}
if(e.key==='r'||e.key==='R'){if(GAME_OVER||WON)this.restart()}
KEYS[e.key]=true;
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault();
});
window.addEventListener('keyup',e=>{KEYS[e.key]=false});
window.addEventListener('blur',()=>{KEYS={}});
}
restart(){
GAME_OVER=false;WON=false;
this.map=new RoomMap();this.player=new Player();
this.cc=0;this.cr=0;this.meleeSwing=null;
this.enterRoom('up');
}
enterRoom(fromDir){
const room=this.cur;
room.visited=true;
this.entryDir=fromDir;
const d=DOOR[fromDir],[ddc,ddr]=DIR[fromDir];
// place player 1 tile inside from the door center
this.player.x=OX+d.cx*TILE+TILE/2-ddc*TILE;
this.player.y=OY+d.cy*TILE+TILE/2-ddr*TILE;
this.player.facing=fromDir;
this.player.invTimer=20;
this.player.transCD=15; // prevent re-transition for 15 frames
this.meleeSwing=null;
room.buildTiles();
room.enemies=[];
room.tears=[];
if(!room.cleared&&room.type!=='spawn'){
this.spawnEnemies(room,fromDir);
}else{
room.cleared=true;
room.buildTiles();
}
}
spawnEnemies(room,entryDir){
const count=room.type==='boss'?1:room.type==='treasure'?0:2+ri(0,2);
for(let i=0;i<count;i++){
let tries=0,x,y,ok;
const type=room.type==='boss'?'boss':Math.random()<0.3?'fast':'normal';
do{
x=OX+2*TILE+rand(0,COLS-4)*TILE;
y=OY+2*TILE+rand(0,ROWS-4)*TILE;
ok=true;
const ed=DOOR[entryDir],dx=OX+ed.cx*TILE+TILE/2,dy=OY+ed.cy*TILE+TILE/2;
if(dist(x,y,dx,dy)<180)ok=false;
for(const e of room.enemies)if(dist(x,y,e.x,e.y)<60)ok=false;
if(dist(x,y,this.player.x,this.player.y)<150)ok=false;
tries++;
}while(!ok&&tries<100);
room.enemies.push(new Enemy(x,y,type));
}
}
isBlocked(room,col,row){
// allow passing through the room boundary at door openings
if(row<0&&room.doors.up&&DOOR.up.cols.includes(col))return false;
if(row>=ROWS&&room.doors.down&&DOOR.down.cols.includes(col))return false;
if(col<0&&room.doors.left&&DOOR.left.rows.includes(row))return false;
if(col>=COLS&&room.doors.right&&DOOR.right.rows.includes(row))return false;
if(row<0||row>=ROWS||col<0||col>=COLS)return true;
return room.tiles[row][col]===T_WALL;
}
collidesWall(ent,room){
const l=Math.floor((ent.x-ent.w/2-OX)/TILE);
const r=Math.floor((ent.x+ent.w/2-OX)/TILE);
const t=Math.floor((ent.y-ent.h/2-OY)/TILE);
const b=Math.floor((ent.y+ent.h/2-OY)/TILE);
for(let row=t;row<=b;row++)
for(let col=l;col<=r;col++)
if(this.isBlocked(room,col,row))return true;
return false;
}
// --- TRANSITION: player stands on a door tile and moves into it ---
checkTransition(){
if(GAME_OVER||WON)return;
if(this.player.transCD>0)return;
const p=this.player,room=this.cur;
if(!room.cleared)return;
const col=Math.floor((p.x-OX)/TILE),row=Math.floor((p.y-OY)/TILE);
// top door
if(row===0&&room.doors.up&&DOOR.up.cols.includes(col)&&(KEYS['w']||KEYS['W']||KEYS['ArrowUp'])){
if(this.map.has(this.cc,this.cr-1)){this.cr--;this.enterRoom('down');return}
}
// bottom door
if(row===ROWS-1&&room.doors.down&&DOOR.down.cols.includes(col)&&(KEYS['s']||KEYS['S']||KEYS['ArrowDown'])){
if(this.map.has(this.cc,this.cr+1)){this.cr++;this.enterRoom('up');return}
}
// left door
if(col===0&&room.doors.left&&DOOR.left.rows.includes(row)&&(KEYS['a']||KEYS['A']||KEYS['ArrowLeft'])){
if(this.map.has(this.cc-1,this.cr)){this.cc--;this.enterRoom('right');return}
}
// right door
if(col===COLS-1&&room.doors.right&&DOOR.right.rows.includes(row)&&(KEYS['d']||KEYS['D']||KEYS['ArrowRight'])){
if(this.map.has(this.cc+1,this.cr)){this.cc++;this.enterRoom('left');return}
}
}
update(){
if(!GAME_OVER&&!WON)this.tick();
this.render();
requestAnimationFrame(()=>this.update());
}
tick(){
const room=this.cur,p=this.player;
if(p.invTimer>0)p.invTimer--;
if(p.atkCD>0)p.atkCD--;
if(p.transCD>0)p.transCD--;
// --- MOVEMENT ---
let mx=0,my=0;
if(KEYS['w']||KEYS['W'])my=-1;
if(KEYS['s']||KEYS['S'])my=1;
if(KEYS['a']||KEYS['A'])mx=-1;
if(KEYS['d']||KEYS['D'])mx=1;
if(mx!==0||my!==0){
const len=Math.hypot(mx,my);mx/=len;my/=len;
if(my<0)p.moveDir='up';else if(my>0)p.moveDir='down';
if(mx<0)p.moveDir='left';else if(mx>0)p.moveDir='right';
const dx=mx*p.speed,dy=my*p.speed;
p.x+=dx;if(this.collidesWall(p,room))p.x-=dx;
p.y+=dy;if(this.collidesWall(p,room))p.y-=dy;
}
// --- ATTACK ---
let ax=0,ay=0;
if(KEYS['ArrowUp'])ax=0,ay=-1;
else if(KEYS['ArrowDown'])ax=0,ay=1;
else if(KEYS['ArrowLeft'])ax=-1,ay=0;
else if(KEYS['ArrowRight'])ax=1,ay=0;
else if(KEYS[' ']||KEYS['Space']){const[fx,fy]=DIR[p.moveDir];ax=fx;ay=fy}
if((ax!==0||ay!==0)&&p.atkCD<=0){
const len=Math.hypot(ax,ay);ax/=len;ay/=len;
const dn=ay<0?'up':ay>0?'down':ax<0?'left':'right';
p.facing=dn;p.atkCD=p.mode===MODE_RANGED?10:22;
if(p.mode===MODE_RANGED)room.tears.push(new Tear(p.x,p.y,ax,ay));
else this.meleeSwing=new MeleeSwing(p.x,p.y,dn);
}
// --- MELEE ---
if(this.meleeSwing&&!this.meleeSwing.alive)this.meleeSwing=null;
if(this.meleeSwing){
this.meleeSwing.life--;
for(const e of room.enemies){
if(!e.alive||e.hitTimer>0)continue;
if(overlap(e.box,this.meleeSwing.box)){
e.hp-=this.meleeSwing.damage;
e.hitTimer=10;
const[dx,dy]=DIR[this.meleeSwing.dir];
e.knx=dx*this.meleeSwing.kb;e.kny=dy*this.meleeSwing.kb;
}
}
}
// --- TEARS ---
for(const t of room.tears){
if(!t.alive)continue;
t.x+=t.dx*t.speed;t.y+=t.dy*t.speed;t.life--;
const col=Math.floor((t.x-OX)/TILE),row=Math.floor((t.y-OY)/TILE);
if(col<0||col>=COLS||row<0||row>=ROWS||t.life<=0){t.life=0;continue}
if(room.tiles[row][col]===T_WALL){t.life=0;continue}
for(const e of room.enemies){
if(!e.alive)continue;
if(dist(t.x,t.y,e.x,e.y)<e.w/2+t.r){e.hp-=t.damage;e.hitTimer=8;t.life=0;break}
}
}
room.tears=room.tears.filter(t=>t.alive);
// --- ENEMY AI ---
let aliveCount=0;
for(const e of room.enemies){
if(!e.alive)continue;
aliveCount++;
if(e.hitTimer>0)e.hitTimer--;
if(Math.abs(e.knx)>0.1||Math.abs(e.kny)>0.1){
e.x+=e.knx*3;e.y+=e.kny*3;e.knx*=0.85;e.kny*=0.85;
continue;
}
e.knx=0;e.kny=0;
const dx=p.x-e.x,dy=p.y-e.y,d=Math.hypot(dx,dy);
if(d>0&&d<500){
const s=e.speed,mx=dx/d*s,my=dy/d*s;
e.x+=mx;if(this.collidesWall(e,room))e.x-=mx;
e.y+=my;if(this.collidesWall(e,room))e.y-=my;
}
if(e.atkTimer>0)e.atkTimer--;
if(dist(e.x,e.y,p.x,p.y)<(e.w+p.w)/2&&p.invTimer<=0&&e.atkTimer<=0){
p.hp-=e.damage;p.invTimer=60;e.atkTimer=30;
if(p.hp<=0){GAME_OVER=true;return}
}
}
// --- ROOM CLEARED ---
if(room.enemies.length>0&&aliveCount===0&&!room.cleared){
room.cleared=true;room.buildTiles();
}
// --- TRANSITION ---
this.checkTransition();
// --- WIN ---
if(!GAME_OVER){
const br=Object.values(this.map.rooms).find(r=>r.type==='boss');
if(br&&br.cleared)WON=true;
}
}
// ============================================================
// RENDER
// ============================================================
render(){
const ctx=this.ctx;
ctx.fillStyle='#0a0a0f';
ctx.fillRect(0,0,CW,CH);
this.drawRoom();
this.drawEntities();
this.drawHUD();
this.drawMinimap();
if(GAME_OVER)this.drawOverlay('#c33','GAME OVER');
else if(WON)this.drawOverlay('#3c3','VICTORY');
}
drawOverlay(c,t){
const ctx=this.ctx;
ctx.fillStyle='rgba(0,0,0,0.8)';ctx.fillRect(0,0,CW,CH);
ctx.fillStyle=c;ctx.font='bold 56px monospace';ctx.textAlign='center';ctx.fillText(t,CW/2,CH/2-20);
ctx.fillStyle='#888';ctx.font='18px monospace';ctx.fillText('[R] restart',CW/2,CH/2+40);
}
drawRoom(){
const ctx=this.ctx,room=this.cur;
// floor
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
const x=OX+c*TILE,y=OY+r*TILE,t=room.tiles[r][c];
if(t===T_WALL){
ctx.fillStyle='#1a1a24';ctx.fillRect(x,y,TILE,TILE);
ctx.fillStyle='#242436';ctx.fillRect(x+2,y+2,TILE-4,TILE-4);
ctx.fillStyle='#1e1e2c';ctx.fillRect(x+4,y+4,TILE-8,TILE-8);
// brick lines
ctx.strokeStyle='#161620';ctx.lineWidth=1;
ctx.beginPath();ctx.moveTo(x,y+TILE/2);ctx.lineTo(x+TILE,y+TILE/2);ctx.stroke();
ctx.beginPath();ctx.moveTo(x+TILE/2,y);ctx.lineTo(x+TILE/2,y+TILE/2);ctx.stroke();
}else if(t===T_DOOR){
ctx.fillStyle='#0d0d14';ctx.fillRect(x,y,TILE,TILE);
ctx.fillStyle='#2a1e0e';ctx.fillRect(x+6,y+6,TILE-12,TILE-12);
ctx.fillStyle='#3a2e14';ctx.fillRect(x+10,y+10,TILE-20,TILE-20);
}else{
const d=(r+c)%2===0;
ctx.fillStyle=d?'#2e2e24':'#353528';
ctx.fillRect(x,y,TILE,TILE);
}
}
// wall overlay gradient at edges
const grad=ctx.createLinearGradient(OX,OY,OX+RW,OY);
ctx.strokeStyle='rgba(0,0,0,0.3)';ctx.lineWidth=2;
ctx.strokeRect(OX,OY,RW,RH);
}
drawEntities(){
const ctx=this.ctx,room=this.cur,p=this.player;
// --- ENEMIES ---
for(const e of room.enemies){
if(!e.alive)continue;
const fl=e.hitTimer>0&&e.hitTimer%4<2;
ctx.save();
// shadow
ctx.fillStyle='rgba(0,0,0,0.3)';ctx.beginPath();ctx.ellipse(e.x+2,e.y+e.h/4,e.w/3,4,0,0,Math.PI*2);ctx.fill();
if(e.type==='boss'){
ctx.fillStyle=fl?'#ddd':'#5a0a0a';
ctx.beginPath();ctx.arc(e.x,e.y,e.w/2,0,Math.PI*2);ctx.fill();
ctx.fillStyle='#4a0808';ctx.beginPath();ctx.arc(e.x-3,e.y-3,e.w/2-4,0,Math.PI*2);ctx.fill();
// eyes
ctx.fillStyle=fl?'#000':'#ff3333';
ctx.beginPath();ctx.arc(e.x-8,e.y-8,5,0,Math.PI*2);ctx.fill();
ctx.beginPath();ctx.arc(e.x+8,e.y-8,5,0,Math.PI*2);ctx.fill();
ctx.fillStyle='#000';ctx.beginPath();ctx.arc(e.x-8,e.y-8,2.5,0,Math.PI*2);ctx.fill();
ctx.beginPath();ctx.arc(e.x+8,e.y-8,2.5,0,Math.PI*2);ctx.fill();
// horns
ctx.fillStyle=fl?'#bbb':'#3a0505';
ctx.beginPath();ctx.moveTo(e.x-16,e.y-e.w/2+4);ctx.lineTo(e.x-8,e.y-e.w/2-16);ctx.lineTo(e.x,e.y-e.w/2+4);ctx.fill();
ctx.beginPath();ctx.moveTo(e.x-4,e.y-e.w/2+4);ctx.lineTo(e.x+4,e.y-e.w/2-16);ctx.lineTo(e.x+12,e.y-e.w/2+4);ctx.fill();
// HP
if(e.hp<e.maxHp){
ctx.fillStyle='#222';ctx.fillRect(e.x-22,e.y-e.h/2-14,44,4);
ctx.fillStyle='#c33';ctx.fillRect(e.x-22,e.y-e.h/2-14,44*(e.hp/e.maxHp),4);
}
}else if(e.type==='fast'){
ctx.fillStyle=fl?'#ddd':'#992222';
ctx.beginPath();ctx.arc(e.x,e.y,e.w/2,0,Math.PI*2);ctx.fill();
ctx.fillStyle='#771111';ctx.beginPath();ctx.arc(e.x-1,e.y-1,e.w/2-3,0,Math.PI*2);ctx.fill();
ctx.fillStyle='#ff4444';
ctx.beginPath();ctx.arc(e.x-5,e.y-4,3,0,Math.PI*2);ctx.fill();
ctx.beginPath();ctx.arc(e.x+5,e.y-4,3,0,Math.PI*2);ctx.fill();
ctx.fillStyle='#000';ctx.beginPath();ctx.arc(e.x-5,e.y-5,1.5,0,Math.PI*2);ctx.fill();
ctx.beginPath();ctx.arc(e.x+5,e.y-5,1.5,0,Math.PI*2);ctx.fill();
}else{
ctx.fillStyle=fl?'#ccc':'#5a4a2e';
ctx.fillRect(e.x-e.w/2,e.y-e.h/2,e.w,e.h);
ctx.fillStyle='#4a3a1e';ctx.fillRect(e.x-e.w/2+3,e.y-e.h/2+3,e.w-6,e.h-6);
ctx.fillStyle='#332816';ctx.fillRect(e.x-e.w/2+6,e.y-e.h/2+6,e.w-12,e.h-12);
ctx.fillStyle='#ffcc66';
ctx.fillRect(e.x-7,e.y-5,5,5);ctx.fillRect(e.x+2,e.y-5,5,5);
ctx.fillStyle='#000';ctx.fillRect(e.x-6,e.y-4,3,3);ctx.fillRect(e.x+3,e.y-4,3,3);
}
ctx.restore();
}
// --- PLAYER ---
ctx.save();
const ifl=p.invTimer>0&&p.invTimer%6<3;
const pCol=p.mode===MODE_RANGED?'#2a6a9a':'#9a3a2a';
ctx.fillStyle=ifl?'#ddd':pCol;
// body shape
ctx.fillRect(p.x-p.w/2,p.y-p.h/2,p.w,p.h);
ctx.fillStyle=ifl?'#ccc':'rgba(0,0,0,0.3)';
ctx.fillRect(p.x-p.w/2+3,p.y-p.h/2+3,p.w-6,p.h-6);
// weapon
const[fx,fy]=DIR[p.facing];
const wx=p.x+fx*(p.w/2+4),wy=p.y+fy*(p.h/2+4);
if(p.mode===MODE_RANGED){
// PISTOL
ctx.strokeStyle=ifl?'#999':'#555';
ctx.lineWidth=3;ctx.lineCap='round';
// barrel
ctx.beginPath();
ctx.moveTo(wx,wy);
ctx.lineTo(wx+fx*14+fy*2,wy+fy*14+fx*2);
ctx.stroke();
// body
ctx.fillStyle=ifl?'#aaa':'#444';
const pw=14,ph=8;
ctx.save();
const rot=fy!==0?Math.PI/2*(fy<0?-1:1):fx<0?Math.PI:0;
ctx.translate(p.x+fx*8,p.y+fy*8);
ctx.rect(-pw/2,-ph/2,pw,ph);
ctx.fill();
ctx.restore();
// muzzle flash on attack
if(p.atkCD>8&&p.mode===MODE_RANGED){
ctx.fillStyle='rgba(255,200,50,0.6)';
ctx.beginPath();ctx.arc(wx+fx*16,wy+fy*16,6,0,Math.PI*2);ctx.fill();
ctx.fillStyle='rgba(255,255,200,0.4)';
ctx.beginPath();ctx.arc(wx+fx*18,wy+fy*18,8,0,Math.PI*2);ctx.fill();
}
}else{
// KNIFE
ctx.strokeStyle=ifl?'#bbb':'#ccc';
ctx.lineWidth=2;
// blade
ctx.beginPath();
const kx=wx+fx*6,ky=wy+fy*6;
ctx.moveTo(kx,ky);
ctx.lineTo(kx+fx*16-fy*6,ky+fy*16+fx*6);
ctx.lineTo(kx+fx*16+fy*6,ky+fy*16-fx*6);
ctx.closePath();
ctx.fillStyle=ifl?'#ddd':'#d4d4d4';
ctx.fill();
ctx.strokeStyle='#999';ctx.stroke();
// handle
ctx.fillStyle=ifl?'#a99':'#5a3a1a';
ctx.fillRect(kx-fx*3-fy*3,ky-fy*3-fx*3,8,8);
// guard
ctx.fillStyle=ifl?'#bbb':'#888';
ctx.fillRect(kx-fx*2-fy*5,ky-fy*2-fx*5,5,12);
}
// eyes
ctx.fillStyle='#fff';
const ex=p.x+fx*5,ey=p.y+fy*5;
ctx.fillRect(ex-5,ey-4,4,5);ctx.fillRect(ex+1,ey-4,4,5);
ctx.fillStyle='#111';
ctx.fillRect(ex-4+fx,ey-3+fy,2,3);ctx.fillRect(ex+2+fx,ey-3+fy,2,3);
ctx.restore();
// --- TEARS ---
for(const t of room.tears){
if(!t.alive)continue;
ctx.save();
ctx.fillStyle='#6699cc';ctx.beginPath();ctx.arc(t.x,t.y,t.r,0,Math.PI*2);ctx.fill();
ctx.fillStyle='#99bbee';ctx.beginPath();ctx.arc(t.x-1.5,t.y-1.5,t.r-2,0,Math.PI*2);ctx.fill();
ctx.restore();
}
// --- MELEE SWING ---
if(this.meleeSwing&&this.meleeSwing.alive){
const s=this.meleeSwing,a=s.life/10;
ctx.save();
ctx.globalAlpha=a*0.35;
ctx.fillStyle='#cc8844';ctx.fillRect(s.box.x,s.box.y,s.box.w,s.box.h);
ctx.globalAlpha=a;
ctx.strokeStyle='#ddbb88';ctx.lineWidth=2;ctx.strokeRect(s.box.x,s.box.y,s.box.w,s.box.h);
ctx.globalAlpha=a*0.8;
ctx.strokeStyle='#ffcc88';ctx.lineWidth=3;
const[dx,dy]=DIR[s.dir];
ctx.beginPath();
ctx.moveTo(s.box.x+s.box.w/2-dx*18,s.box.y+s.box.h/2-dy*18);
ctx.lineTo(s.box.x+s.box.w/2+dx*18,s.box.y+s.box.h/2+dy*18);
ctx.stroke();
ctx.restore();
}
}
drawHUD(){
const ctx=this.ctx,p=this.player;
// --- HP BAR ---
const bx=20,by=20,bw=140,bh=14;
ctx.fillStyle='#111';ctx.fillRect(bx,by,bw,bh);
ctx.fillStyle='#2a0a0a';ctx.fillRect(bx+2,by+2,bw-4,bh-4);
const hpRatio=Math.max(0,p.hp/p.maxHp);
const hpCol=hpRatio>0.5?'#993333':hpRatio>0.25?'#994422':'#663322';
ctx.fillStyle=hpCol;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);
// --- MODE INDICATOR ---
const my=CH-46;
ctx.textAlign='center';
const mText=p.mode===MODE_RANGED?'RANGED':'MELEE';
const mCol=p.mode===MODE_RANGED?'#4488cc':'#cc6644';
ctx.fillStyle='#0d0d0d';ctx.fillRect(CW/2-95,my-18,190,34);
ctx.strokeStyle=mCol;ctx.lineWidth=2;ctx.strokeRect(CW/2-95,my-18,190,34);
ctx.fillStyle=mCol;ctx.font='bold 17px monospace';ctx.fillText(`[ ${mText} ]`,CW/2,my+8);
ctx.fillStyle='#555';ctx.font='11px monospace';ctx.fillText('[Tab] switch',CW/2,my-26);
// weapon icon in mode indicator
if(p.mode===MODE_RANGED){
ctx.strokeStyle='#88bbdd';ctx.lineWidth=2;
ctx.beginPath();ctx.moveTo(CW/2-82,my-4);ctx.lineTo(CW/2-72,my-4);ctx.stroke();
ctx.fillStyle='#88bbdd';ctx.fillRect(CW/2-82,my-8,10,8);
}else{
ctx.fillStyle='#ddbb88';
ctx.beginPath();
ctx.moveTo(CW/2-82,my-10);ctx.lineTo(CW/2-74,my-2);ctx.lineTo(CW/2-82,my+4);ctx.fill();
}
// --- ENEMY COUNT ---
ctx.textAlign='left';
const room=this.cur;
const alive=room.enemies.filter(e=>e.alive).length;
if(alive>0){
ctx.fillStyle='#aa4444';ctx.font='13px monospace';ctx.fillText(`\u25B6 ${alive}`,20,CH-18);
}else if(!room.cleared&&room.type!=='spawn'){
ctx.fillStyle='#886633';ctx.font='13px monospace';ctx.fillText('Clear the room',20,CH-18);
}
// --- ROOM TYPE ---
if(room.visited){
ctx.textAlign='right';
const tn={spawn:'START',normal:'',treasure:'TREASURE',boss:'BOSS'}[room.type];
if(tn){
ctx.fillStyle='#555';ctx.font='11px monospace';ctx.fillText(tn,CW-20,OY+RH+30);
}
}
}
drawMinimap(){
const ctx=this.ctx;
const mx=CW-180,my=12,cell=14,gap=2,cs=cell+gap;
ctx.fillStyle='rgba(0,0,0,0.75)';ctx.fillRect(mx-8,my-8,cs*7+16,cs*7+16);
ctx.strokeStyle='#333';ctx.lineWidth=1;
ctx.strokeRect(mx-8,my-8,cs*7+16,cs*7+16);
for(let r=-3;r<=3;r++)for(let c=-3;c<=3;c++){
const room=this.map.get(this.cc+c,this.cr+r);
if(!room)continue;
const x=mx+(c+3)*cs,y=my+(r+3)*cs;
let color='#141414';
if(room.visited){
const cl={spawn:'#2a5a2a',boss:'#5a1a1a',treasure:'#5a5a1a'}[room.type]||'#555';
color=cl;
}
ctx.fillStyle=color;ctx.fillRect(x,y,cell,cell);
if(room.visited){
ctx.strokeStyle='rgba(255,255,255,0.12)';ctx.lineWidth=1;
if(room.doors.up)ctx.fillRect(x+cs/2-2,y-2,4,3);
if(room.doors.down)ctx.fillRect(x+cs/2-2,y+cell-1,4,3);
if(room.doors.left)ctx.fillRect(x-2,y+cs/2-2,3,4);
if(room.doors.right)ctx.fillRect(x+cell-1,y+cs/2-2,3,4);
}
if(c===0&&r===0){
ctx.strokeStyle='#ddd';ctx.lineWidth=2;ctx.strokeRect(x-1.5,y-1.5,cell+3,cell+3);
}
}
}
}
// ============================================================
// START
// ============================================================
window.addEventListener('load',()=>{game=new Game()});
</script>
<!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. -->
<div id="menu">
<h1><img id="menu-logo" src="assets/logo.png" alt="Биндим Фигняшку" /></h1>
<p class="menu-sub">Выбери уровень</p>
<div id="menu-presets"></div>
<p class="menu-hint">WASD — движение · Стрелки — стрельба · Tab — оружие · Esc — меню</p>
</div>
</div>
<script type="module" src="./main.js"></script>
</body>
</html>
+11 -1
View File
@@ -6,6 +6,16 @@
"scripts": {
"dev": "bun run dev.ts",
"build": "bun run build.ts",
"start": "bun run build && echo 'open dist/index.html'"
"typecheck": "tsc --noEmit",
"test": "bun test",
"check": "bun run typecheck && bun test",
"start": "bun run build && echo 'Готово — открой dist/index.html'"
},
"dependencies": {
"three": "^0.184.0"
},
"devDependencies": {
"@types/three": "^0.184.1",
"typescript": "^6.0.3"
}
}
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

+149
View File
@@ -0,0 +1,149 @@
/**
* config.ts — ВСЕ настройки игры в одном месте.
*
* Здесь сосредоточены: размеры мира, геометрия комнат/дверей и баланс
* (скорости, здоровье, перезарядки). Меняй числа тут, чтобы «крутить»
* игру — больше ничего трогать не нужно.
*
* ВАЖНО: единица времени — один «шаг» симуляции (= 1/60 секунды), а НЕ
* кадр браузера. Цикл игры (см. engine/GameLoop.ts) гарантирует ровно
* 60 шагов в секунду на любом мониторе, поэтому скорости заданы «пикселей
* за шаг», а перезарядки — «в шагах».
*/
// ─────────────────────────────────────────────────────────────
// Холст и сетка
// ─────────────────────────────────────────────────────────────
export const CW = 880; // ширина области отрисовки (px)
export const CH = 660; // высота области отрисовки (px)
export const TILE = 44; // размер одного тайла (px)
export const COLS = 15; // тайлов по горизонтали в комнате
export const ROWS = 11; // тайлов по вертикали в комнате
export const RW = COLS * TILE; // ширина комнаты в пикселях
export const RH = ROWS * TILE; // высота комнаты в пикселях
export const OX = (CW - RW) / 2; // отступ комнаты слева
export const OY = 80; // отступ комнаты сверху (место под HUD)
// Частота симуляции. Логика всегда обновляется с этим шагом.
export const FIXED_FPS = 60;
export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг
export const GAME_LOOP = {
maxFrameTimeSec: 0.25, // не «отыгрывать» долгие паузы (фон/таб)
maxStepsPerFrame: 5, // защита от «спирали смерти» при лагах
};
// ─────────────────────────────────────────────────────────────
// Типы тайлов
// ─────────────────────────────────────────────────────────────
export const T_WALL = 0;
export const T_FLOOR = 1;
export const T_DOOR = 2;
// ─────────────────────────────────────────────────────────────
// Режимы боя
// ─────────────────────────────────────────────────────────────
export const MODE_RANGED = 0;
export const MODE_MELEE = 1;
// ─────────────────────────────────────────────────────────────
// Направления
// ─────────────────────────────────────────────────────────────
import type { Dir } from './core/types';
/** Единичный вектор смещения для каждого направления (x вправо, y вниз). */
export const DIR: Record<Dir, readonly [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
/** Противоположное направление. Используется при простановке дверей соседей. */
export const OPP: Record<Dir, Dir> = {
up: 'down',
down: 'up',
left: 'right',
right: 'left',
};
/**
* Геометрия дверей: проём шириной в 3 тайла по центру каждой стороны.
* cx/cy — координата тайла-центра двери (для спавна игрока и расчётов).
*/
export const DOOR = {
up: { cols: [6, 7, 8] as number[], row: 0, cx: 7, cy: 0 },
down: { cols: [6, 7, 8] as number[], row: ROWS - 1, cx: 7, cy: ROWS - 1 },
left: { col: 0, rows: [4, 5, 6] as number[], cx: 0, cy: 5 },
right: { col: COLS - 1, rows: [4, 5, 6] as number[], cx: COLS - 1, cy: 5 },
};
// ─────────────────────────────────────────────────────────────
// Генерация карты (случайное блуждание)
// ─────────────────────────────────────────────────────────────
export const MAP_RADIUS = 3; // карта вмещается в сетку (2*R+1)²
export const MIN_ROOMS = 8; // минимум комнат
export const EXTRA_ROOMS = 4; // + случайно до этого числа
// ─────────────────────────────────────────────────────────────
// Баланс: игрок
// ─────────────────────────────────────────────────────────────
export const PLAYER = {
size: 26,
speed: 3.2, // px за шаг
maxHp: 6,
invFrames: 60, // неуязвимость после удара, в шагах
entryInvFrames: 20, // короткая неуязвимость при входе в комнату, в шагах
rangedCooldown: 10, // перезарядка выстрела, в шагах
meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
transitionLock: 15, // блок повторного перехода между комнатами, в шагах
};
// ─────────────────────────────────────────────────────────────
// Баланс: снаряд (слеза) и ближний бой
// ─────────────────────────────────────────────────────────────
export const PROJECTILE = {
radius: 5,
speed: 7, // px за шаг
damage: 1,
life: 80, // время жизни в шагах
};
export const MELEE = {
reach: 22, // отступ хитбокса от центра игрока
size: 50, // сторона квадратного хитбокса
life: 10, // длительность взмаха в шагах
damage: 2,
knockback: 10,
};
// ─────────────────────────────────────────────────────────────
// Баланс: враги (таблица характеристик по типу)
// ─────────────────────────────────────────────────────────────
export const ENEMY_STATS = {
normal: { size: 32, hp: 3, speed: 1.15, damage: 1 },
fast: { size: 26, hp: 2, speed: 1.9, damage: 1 },
boss: { size: 46, hp: 10, speed: 0.9, damage: 2 },
} as const;
export const ENEMY = {
aggroRange: 500, // дистанция, с которой враг начинает преследование
attackCooldown: 30, // пауза между контактными ударами, в шагах
hitFlash: 8, // длительность «мигания» при попадании, в шагах
knockbackDecay: 0.85,
fastChance: 0.3, // доля быстрых врагов в обычной комнате
};
// ─────────────────────────────────────────────────────────────
// Спавн врагов
// ─────────────────────────────────────────────────────────────
export const SPAWN = {
normalMin: 2, // минимум врагов в обычной комнате
normalExtra: 2, // + случайно до этого числа
minDistFromDoor: 180, // не спавнить ближе к двери входа
minDistFromPlayer: 150,
minDistBetween: 60,
maxPlacementTries: 100,
treasureChance: 0.12, // шанс комнаты-сокровищницы
bossChance: 0.2, // шанс назначить комнату боссом
};
-48
View File
@@ -1,48 +0,0 @@
// Canvas & grid dimensions
export const CW = 880;
export const CH = 660;
export const TILE = 44;
export const COLS = 15;
export const ROWS = 11;
export const RW = COLS * TILE;
export const RH = ROWS * TILE;
export const OX = (CW - RW) / 2;
export const OY = 80;
// Tile types
export const T_WALL = 0;
export const T_FLOOR = 1;
export const T_DOOR = 2;
// Combat modes
export const MODE_RANGED = 0;
export const MODE_MELEE = 1;
// Direction vectors
export const DIR: Record<string, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
// Opposite direction lookup
export const OPP: Record<string, string> = {
up: 'bottom',
down: 'top',
left: 'right',
right: 'left',
};
// Door opening geometry (3 tiles wide at each cardinal edge)
export const DOOR = {
up: { cols: [6, 7, 8], row: 0, cx: 7, cy: 0 },
down: { cols: [6, 7, 8], row: 10, cx: 7, cy: 10 },
left: { col: 0, rows: [4, 5, 6], cx: 0, cy: 5 },
right: { col: 14, rows: [4, 5, 6], cx: 14, cy: 5 },
} as const;
// Grid generation bounds
export const MAP_RADIUS = 3;
export const MIN_ROOMS = 8;
export const EXTRA_ROOMS = 4;
+326
View File
@@ -0,0 +1,326 @@
import {
DIR, DOOR, OX, OY, TILE, COLS, ROWS, T_WALL,
MODE_RANGED, MODE_MELEE, PLAYER, ENEMY, MELEE,
} from '../config';
import type { Dir } from './types';
import { Rng } from './rng';
import { dist, overlap } from './util';
import { Player } from './entities/Player';
import { Projectile } from './entities/Projectile';
import { MeleeSwing } from './entities/MeleeSwing';
import { RoomMap } from './world/RoomMap';
import type { Room } from './world/Room';
import { collidesWall } from './systems/collision';
import { spawnEnemies } from './systems/spawner';
import { DEFAULT_RULES, type LevelRules } from './rules';
import type { InputState } from '../input/InputState';
import { pressingDir } from '../input/InputState';
/**
* Game — «мозг» игры. Полностью независим от рендера и DOM: ничего не
* рисует и не знает про three.js/canvas. Хранит всё изменяемое состояние
* и продвигает симуляцию ровно на один фиксированный шаг в step().
*
* Контракт с внешним миром:
* • consumeActions(input) — один раз за кадр: смена оружия, рестарт;
* • step(input) — один фиксированный шаг физики/логики;
* • публичные геттеры/поля — читает рендер.
*/
export class Game {
readonly rules: LevelRules;
rng: Rng; // пересоздаётся в reset() — для воспроизводимости фикс-сида
roomMap: RoomMap;
player: Player;
cc = 0; // координаты текущей комнаты на карте
cr = 0;
meleeSwing: MeleeSwing | null = null;
gameOver = false;
won = false;
/**
* @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт».
* @param rng опционально свой ГПСЧ; иначе берётся seed из правил (или случайный).
*/
constructor(rules: LevelRules = DEFAULT_RULES, rng?: Rng) {
this.rules = rules;
this.rng = rng ?? new Rng(rules.seed);
this.player = new Player(rules.player);
this.roomMap = new RoomMap(this.rng, rules);
this.enterRoom('up');
}
/** Текущая комната (всегда существует: карта связна и переходы — только в имеющиеся комнаты). */
get curRoom(): Room {
return this.roomMap.get(this.cc, this.cr)!;
}
// ── Публичный контракт цикла ──────────────────────────────
/** Однократные действия (смена оружия, рестарт). Вызывать раз в кадр. */
consumeActions(input: InputState): void {
if (input.toggleWeapon && !this.gameOver && !this.won) {
this.player.mode = this.player.mode === MODE_RANGED ? MODE_MELEE : MODE_RANGED;
}
if (input.restart && (this.gameOver || this.won)) {
this.reset();
}
}
/** Один фиксированный шаг симуляции (= 1/60 c). */
step(input: InputState): void {
if (this.gameOver || this.won) return;
const room = this.curRoom;
const p = this.player;
// Запоминаем позиции для плавной интерполяции при рендере.
p.prevX = p.x; p.prevY = p.y;
for (const e of room.enemies) { e.prevX = e.x; e.prevY = e.y; }
for (const t of room.tears) { t.prevX = t.x; t.prevY = t.y; }
// Таймеры.
if (p.invTimer > 0) p.invTimer--;
if (p.atkCD > 0) p.atkCD--;
if (p.transCD > 0) p.transCD--;
this.movePlayer(input, room, p);
this.handleAttack(input, room, p);
this.updateMelee(room);
this.updateTears(room);
const aliveCount = this.updateEnemies(room, p);
if (this.gameOver) return;
// Комната зачищена: открываем двери.
if (room.enemies.length > 0 && aliveCount === 0 && !room.cleared) {
room.cleared = true;
room.rebuildTiles();
}
this.checkTransition(input);
this.checkWin();
}
/** Полный сброс — новая карта, новый игрок (рестарт после конца игры). */
reset(): void {
this.gameOver = false;
this.won = false;
// Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз.
this.rng = new Rng(this.rules.seed);
this.roomMap = new RoomMap(this.rng, this.rules);
this.player = new Player(this.rules.player);
this.cc = 0;
this.cr = 0;
this.meleeSwing = null;
this.enterRoom('up');
}
// ── Переход между комнатами ───────────────────────────────
/** Расставляет игрока внутри текущей комнаты у двери fromDir и (при нужде) спавнит врагов. */
enterRoom(fromDir: Dir): void {
const room = this.curRoom;
room.visited = true;
const d = DOOR[fromDir];
const [ddc, ddr] = DIR[fromDir];
// Ставим игрока на один тайл внутрь от центра двери.
const px = OX + d.cx * TILE + TILE / 2 - ddc * TILE;
const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
this.player.place(px, py);
this.player.facing = fromDir;
this.player.invTimer = PLAYER.entryInvFrames;
this.player.transCD = PLAYER.transitionLock;
this.meleeSwing = null;
room.tears = [];
if (!room.cleared && room.type !== 'spawn') {
room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.rules);
// Если врагов нет (напр. сокровищница) — зачищать нечего, открываем сразу,
// иначе двери никогда не появятся и игрок застрянет.
if (room.enemies.length === 0) room.cleared = true;
room.rebuildTiles();
} else {
room.cleared = true;
room.enemies = [];
room.rebuildTiles();
}
}
// ── Системы (по одному шагу) ──────────────────────────────
private movePlayer(input: InputState, room: Room, p: Player): void {
let mx = input.moveX;
let my = input.moveY;
if (mx === 0 && my === 0) return;
const len = Math.hypot(mx, my);
mx /= len;
my /= len;
if (input.moveY < 0) p.moveDir = 'up';
else if (input.moveY > 0) p.moveDir = 'down';
if (input.moveX < 0) p.moveDir = 'left';
else if (input.moveX > 0) p.moveDir = 'right';
const dx = mx * p.speed;
const dy = my * p.speed;
// Раздельное разрешение коллизий по осям: позволяет «скользить» вдоль стен.
p.x += dx;
if (collidesWall(p.box, room)) p.x -= dx;
p.y += dy;
if (collidesWall(p.box, room)) p.y -= dy;
}
private handleAttack(input: InputState, room: Room, p: Player): void {
let dir: Dir | null = null;
if (input.aimDir) dir = input.aimDir; // прицельная стрельба стрелками
else if (input.attackHeld) dir = p.moveDir; // пробел — по ходу движения
if (!dir || p.atkCD > 0) return;
p.facing = dir;
p.atkCD = p.mode === MODE_RANGED ? PLAYER.rangedCooldown : PLAYER.meleeCooldown;
const [nx, ny] = DIR[dir];
if (p.mode === MODE_RANGED) {
room.tears.push(new Projectile(p.x, p.y, nx, ny));
} else {
this.meleeSwing = new MeleeSwing(p.x, p.y, dir);
}
}
private updateMelee(room: Room): void {
if (this.meleeSwing && !this.meleeSwing.alive) this.meleeSwing = null;
if (!this.meleeSwing) return;
this.meleeSwing.life--;
for (const e of room.enemies) {
if (!e.alive || e.hitTimer > 0) continue;
if (overlap(e.box, this.meleeSwing.box)) {
e.hp -= this.meleeSwing.damage;
e.hitTimer = MELEE.life; // защита от повторного удара тем же взмахом
const [dx, dy] = DIR[this.meleeSwing.dir];
e.knx = dx * this.meleeSwing.kb;
e.kny = dy * this.meleeSwing.kb;
}
}
}
private updateTears(room: Room): void {
for (const t of room.tears) {
if (!t.alive) continue;
t.x += t.dx * t.speed;
t.y += t.dy * t.speed;
t.life--;
const col = Math.floor((t.x - OX) / TILE);
const row = Math.floor((t.y - OY) / TILE);
if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) {
t.life = 0;
continue;
}
if (room.tiles[row][col] === T_WALL) {
t.life = 0;
continue;
}
for (const e of room.enemies) {
if (!e.alive) continue;
if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + t.r) {
e.hp -= t.damage;
e.hitTimer = ENEMY.hitFlash;
t.life = 0;
break;
}
}
}
room.tears = room.tears.filter((t) => t.alive);
}
private updateEnemies(room: Room, p: Player): number {
let aliveCount = 0;
for (const e of room.enemies) {
if (!e.alive) continue;
aliveCount++;
if (e.hitTimer > 0) e.hitTimer--;
// Фаза отбрасывания: летит по инерции, ИИ не работает. Коллизии
// проверяем пораздельно по осям — иначе кнокбэк (до ~4.5 тайла)
// пробивал стену в 1 тайл, и враг застревал снаружи навсегда (софт-лок).
if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) {
e.x += e.knx * 3;
if (collidesWall(e.box, room)) e.x -= e.knx * 3;
e.y += e.kny * 3;
if (collidesWall(e.box, room)) e.y -= e.kny * 3;
e.knx *= ENEMY.knockbackDecay;
e.kny *= ENEMY.knockbackDecay;
continue;
}
e.knx = 0;
e.kny = 0;
// Преследование игрока.
const dx = p.x - e.x;
const dy = p.y - e.y;
const d = Math.hypot(dx, dy);
if (d > 0 && d < ENEMY.aggroRange) {
const mx = (dx / d) * e.speed;
const my = (dy / d) * e.speed;
e.x += mx;
if (collidesWall(e.box, room)) e.x -= mx;
e.y += my;
if (collidesWall(e.box, room)) e.y -= my;
}
// Контактный урон по игроку.
if (e.atkTimer > 0) e.atkTimer--;
if (dist(e.x, e.y, p.x, p.y) < (e.w + p.w) / 2 && p.invTimer <= 0 && e.atkTimer <= 0) {
p.hp -= e.damage;
p.invTimer = PLAYER.invFrames;
e.atkTimer = ENEMY.attackCooldown;
if (p.hp <= 0) {
p.hp = 0;
this.gameOver = true;
return aliveCount;
}
}
}
return aliveCount;
}
// ── Переходы и победа ─────────────────────────────────────
private checkTransition(input: InputState): void {
const p = this.player;
if (p.transCD > 0) return;
const room = this.curRoom;
if (!room.cleared) return;
const col = Math.floor((p.x - OX) / TILE);
const row = Math.floor((p.y - OY) / TILE);
if (row === 0 && room.doors.up && DOOR.up.cols.includes(col) && pressingDir(input, 'up')) {
if (this.roomMap.has(this.cc, this.cr - 1)) { this.cr--; this.enterRoom('down'); return; }
}
if (row === ROWS - 1 && room.doors.down && DOOR.down.cols.includes(col) && pressingDir(input, 'down')) {
if (this.roomMap.has(this.cc, this.cr + 1)) { this.cr++; this.enterRoom('up'); return; }
}
if (col === 0 && room.doors.left && DOOR.left.rows.includes(row) && pressingDir(input, 'left')) {
if (this.roomMap.has(this.cc - 1, this.cr)) { this.cc--; this.enterRoom('right'); return; }
}
if (col === COLS - 1 && room.doors.right && DOOR.right.rows.includes(row) && pressingDir(input, 'right')) {
if (this.roomMap.has(this.cc + 1, this.cr)) { this.cc++; this.enterRoom('left'); return; }
}
}
private checkWin(): void {
for (const room of this.roomMap.rooms.values()) {
if (room.type === 'boss' && room.cleared) {
this.won = true;
return;
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import { ENEMY_STATS } from '../../config';
import type { Box, EnemyType } from '../types';
/**
* Враг. Характеристики берутся из таблицы ENEMY_STATS по типу.
* Чтобы добавить новый тип врага — допиши строку в ENEMY_STATS (config.ts)
* и тип в EnemyType (core/types.ts). Логика и спавн подхватят автоматически.
*/
export class Enemy {
x: number;
y: number;
prevX: number;
prevY: number;
readonly type: EnemyType;
readonly w: number;
readonly h: number;
hp: number;
readonly maxHp: number;
readonly speed: number;
readonly damage: number;
knx = 0; // отбрасывание по X
kny = 0; // отбрасывание по Y
hitTimer = 0; // мигание при попадании (шаги)
atkTimer = 0; // перезарядка контактного удара (шаги)
/**
* mods — множители из правил уровня (см. core/rules.ts). По умолчанию 1,
* поэтому `new Enemy(x, y, type)` даёт базовый баланс из ENEMY_STATS.
*/
constructor(x: number, y: number, type: EnemyType, mods: { hpMul?: number; speedMul?: number } = {}) {
this.x = this.prevX = x;
this.y = this.prevY = y;
this.type = type;
const s = ENEMY_STATS[type];
this.w = s.size;
this.h = s.size;
this.maxHp = Math.max(1, Math.round(s.hp * (mods.hpMul ?? 1)));
this.hp = this.maxHp;
this.speed = s.speed * (mods.speedMul ?? 1);
this.damage = s.damage;
}
get box(): Box {
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
}
get alive(): boolean {
return this.hp > 0;
}
}
@@ -1,17 +1,17 @@
import { MELEE, DIR } from '../../config';
import type { Dir, Box } from '../types';
import { DIR } from '../constants';
/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на MELEE.life шагов. */
export class MeleeSwing {
dir: Dir;
life = 10;
damage = 2;
kb = 10;
box: Box;
readonly dir: Dir;
life = MELEE.life;
readonly damage = MELEE.damage;
readonly kb = MELEE.knockback;
readonly box: Box;
constructor(x: number, y: number, dir: Dir) {
this.dir = dir;
const d = 22;
const s = 50;
const { reach: d, size: s } = MELEE;
const [dx, dy] = DIR[dir];
this.box = {
x: x + (dx > 0 ? d : dx < 0 ? -d - s : -s / 2),
+43
View File
@@ -0,0 +1,43 @@
import { PLAYER, MODE_RANGED } from '../../config';
import type { CombatMode, Box, Dir } from '../types';
/**
* Игрок. Только данные и геометрия — никакой отрисовки.
* prevX/prevY хранят позицию на прошлом шаге для плавной интерполяции
* при рендере (см. render/).
*/
export class Player {
x = 0;
y = 0;
prevX = 0;
prevY = 0;
readonly w = PLAYER.size;
readonly h = PLAYER.size;
readonly speed: number;
hp: number;
readonly maxHp: number;
mode: CombatMode = MODE_RANGED;
facing: Dir = 'up'; // куда смотрит/целится
moveDir: Dir = 'up'; // последнее направление движения
atkCD = 0; // перезарядка атаки (шаги)
invTimer = 0; // неуязвимость (шаги)
transCD = 0; // блок перехода между комнатами (шаги)
/** Переопределения из правил уровня; по умолчанию — баланс из config. */
constructor(rules: { maxHp?: number; speed?: number } = {}) {
this.maxHp = rules.maxHp ?? PLAYER.maxHp;
this.hp = this.maxHp;
this.speed = rules.speed ?? PLAYER.speed;
}
/** Хитбокс с центром в (x, y). */
get box(): Box {
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
}
/** Поставить позицию мгновенно, сбросив интерполяцию (телепорт). */
place(x: number, y: number): void {
this.x = this.prevX = x;
this.y = this.prevY = y;
}
}
+26
View File
@@ -0,0 +1,26 @@
import { PROJECTILE } from '../../config';
/** Снаряд игрока («слеза»). Летит по прямой, пока не врежется или не истечёт life. */
export class Projectile {
x: number;
y: number;
prevX: number;
prevY: number;
dx: number;
dy: number;
readonly r = PROJECTILE.radius;
readonly speed = PROJECTILE.speed;
readonly damage = PROJECTILE.damage;
life = PROJECTILE.life;
constructor(x: number, y: number, dx: number, dy: number) {
this.x = this.prevX = x;
this.y = this.prevY = y;
this.dx = dx;
this.dy = dy;
}
get alive(): boolean {
return this.life > 0;
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* rng.ts — генератор случайных чисел с поддержкой seed.
*
* Зачем не просто Math.random(): с фиксированным seed карта и спавн
* становятся воспроизводимыми. Это бесценно для отладки («дай мне ту же
* самую кривую генерацию») и для тестов (см. tests/). Вся игровая логика
* получает один экземпляр Rng и использует только его — никаких прямых
* вызовов Math.random() в ядре.
*/
export class Rng {
private state: number;
/** Без seed — случайный старт; с seed — детерминированная цепочка. */
constructor(seed: number = autoSeed()) {
// 0 — валидный seed; авто-seed включается только когда аргумент не передан.
this.state = seed >>> 0;
}
/** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
next(): number {
this.state = (this.state + 0x6d2b79f5) >>> 0;
let t = this.state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
/** Случайное вещественное в [a, b). */
float(a: number, b: number): number {
return this.next() * (b - a) + a;
}
/** Случайное целое в [a, b] включительно. */
int(a: number, b: number): number {
return Math.floor(this.float(a, b + 1));
}
/** true с вероятностью p (0..1). */
chance(p: number): boolean {
return this.next() < p;
}
/** Случайный элемент массива. */
pick<T>(arr: readonly T[]): T {
if (arr.length === 0) throw new Error('Rng.pick: пустой массив');
return arr[this.int(0, arr.length - 1)];
}
/** Перемешивание Фишера–Йейтса на месте. */
shuffle<T>(arr: T[]): T[] {
for (let i = arr.length - 1; i > 0; i--) {
const j = this.int(0, i);
[arr[i], arr[j]] = [arr[j], arr[i]];
}
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;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* rules.ts — ПРАВИЛА УРОВНЯ (конфигурация забега).
*
* Мир по-прежнему генерируется процедурно (каждый забег — новый), но теперь
* параметризуется набором правил: размер данжена, плотность и сила врагов,
* здоровье игрока, фиксированный seed. На старте игрок выбирает один из
* пресетов (меню), и `Game` создаётся с этими правилами.
*
* Как добавить свой уровень: допиши объект в PRESETS — он сразу появится в меню.
* Геометрия (размер тайла/комнаты, геометрия дверей) остаётся в config.ts: это
* не «правила уровня», а константы движка.
*/
import { PLAYER, ENEMY, MIN_ROOMS, EXTRA_ROOMS, MAP_RADIUS } from '../config';
export interface LevelRules {
/** Машинный id (для сохранений/выбора). */
id: string;
/** Название для меню. */
name: string;
/** Короткое описание для меню. */
description: string;
/** Фиксированный seed генерации. undefined → случайный каждый забег. */
seed?: number;
/** Параметры генерации карты. */
map: {
minRooms: number;
extraRooms: number;
mapRadius: number;
};
/** Параметры игрока. */
player: {
maxHp: number;
speed: number;
};
/** Параметры врагов (множители поверх базовых из config.ENEMY_STATS). */
enemies: {
densityMul: number; // множитель числа врагов в обычной комнате
fastChance: number; // доля быстрых врагов
hpMul: number; // множитель HP всех врагов
speedMul: number; // множитель скорости
bossHpMul: number; // отдельный множитель HP босса
};
}
/** Базовые правила = текущий «ванильный» баланс из config. */
export const DEFAULT_RULES: LevelRules = {
id: 'standard',
name: 'Стандарт',
description: 'Классический забег. Сбалансированный данжен.',
map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
};
/** Пресеты для меню. Первый — по умолчанию. */
export const PRESETS: LevelRules[] = [
DEFAULT_RULES,
{
id: 'big',
name: 'Большой данжен',
description: 'Больше комнат — длиннее забег.',
map: { minRooms: 14, extraRooms: 6, mapRadius: 4 },
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
},
{
id: 'hardcore',
name: 'Хардкор',
description: 'Мало HP, больше быстрых и живучих врагов.',
map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
player: { maxHp: 3, speed: PLAYER.speed },
enemies: { densityMul: 1.5, fastChance: 0.55, hpMul: 1.4, speedMul: 1.15, bossHpMul: 1.5 },
},
{
id: 'explorer',
name: 'Исследователь',
description: 'Мирно: много HP, мало слабых врагов — просто ходить и изучать.',
map: { minRooms: 12, extraRooms: 4, mapRadius: 4 },
player: { maxHp: 10, speed: PLAYER.speed * 1.1 },
enemies: { densityMul: 0.5, fastChance: 0.15, hpMul: 0.7, speedMul: 0.9, bossHpMul: 0.8 },
},
{
id: 'daily',
name: 'Фикс-сид',
description: 'Один и тот же данжен каждый раз (seed=2026) — удобно тренироваться.',
seed: 2026,
map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
},
];
+33
View File
@@ -0,0 +1,33 @@
import { ROWS, COLS, DOOR, TILE, OX, OY, T_WALL } from '../../config';
import type { Room } from '../world/Room';
import type { Box } from '../types';
/**
* Заблокирован ли тайл (col, row) для движения.
* В дверных проёмах граница комнаты «прозрачна» — это позволяет хитбоксу
* заехать за край и встать на дверь для перехода в соседнюю комнату.
*/
export function isBlocked(room: Room, col: number, row: number): boolean {
if (row < 0 && room.doors.up && DOOR.up.cols.includes(col)) return false;
if (row >= ROWS && room.doors.down && DOOR.down.cols.includes(col)) return false;
if (col < 0 && room.doors.left && DOOR.left.rows.includes(row)) return false;
if (col >= COLS && room.doors.right && DOOR.right.rows.includes(row)) return false;
if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return true;
return room.tiles[row][col] === T_WALL;
}
/** Пересекает ли хитбокс хотя бы один заблокированный тайл. */
export function collidesWall(box: Box, room: Room): boolean {
const left = Math.floor((box.x - OX) / TILE);
const right = Math.floor((box.x + box.w - OX) / TILE);
const top = Math.floor((box.y - OY) / TILE);
const bottom = Math.floor((box.y + box.h - OY) / TILE);
for (let row = top; row <= bottom; row++) {
for (let col = left; col <= right; col++) {
if (isBlocked(room, col, row)) return true;
}
}
return false;
}
+74
View File
@@ -0,0 +1,74 @@
import { OX, OY, TILE, COLS, ROWS, DOOR, SPAWN } from '../../config';
import { Enemy } from '../entities/Enemy';
import { dist } from '../util';
import type { Room } from '../world/Room';
import type { Dir, EnemyType } from '../types';
import type { Rng } from '../rng';
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;
}
/**
* Подбирает врагов для комнаты и расставляет их так, чтобы они не появились
* вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов
* берутся из правил уровня (rules). Возвращает массив — вызывающий код кладёт
* его в room.enemies.
*/
export function spawnEnemies(
room: Room,
entryDir: Dir,
playerX: number,
playerY: number,
rng: Rng,
rules: LevelRules = DEFAULT_RULES,
): Enemy[] {
const enemies: Enemy[] = [];
const er = rules.enemies;
const count =
room.type === 'boss' ? 1 :
room.type === 'treasure' ? 0 :
Math.max(1, Math.round((SPAWN.normalMin + rng.int(0, SPAWN.normalExtra)) * er.densityMul));
const door = DOOR[entryDir];
const doorX = OX + door.cx * TILE + TILE / 2;
const doorY = OY + door.cy * TILE + TILE / 2;
for (let i = 0; i < count; i++) {
const type: EnemyType =
room.type === 'boss' ? 'boss' : rng.chance(er.fastChance) ? 'fast' : 'normal';
const mods = {
hpMul: er.hpMul * (type === 'boss' ? er.bossHpMul : 1),
speedMul: er.speedMul,
};
let x = 0;
let y = 0;
let ok = false;
for (let tries = 0; tries < SPAWN.maxPlacementTries && !ok; tries++) {
x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
ok = isSpawnSpotClear(x, y, doorX, doorY, playerX, playerY, enemies);
}
if (!ok) continue;
enemies.push(new Enemy(x, y, type, mods));
}
return enemies;
}
+5
View File
@@ -1,7 +1,11 @@
/** Общие типы данных, на которые опирается вся игра. */
export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss';
export type Dir = 'up' | 'down' | 'left' | 'right';
export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE
export type EnemyType = 'normal' | 'fast' | 'boss';
/** Прямоугольник (axis-aligned bounding box) для коллизий. */
export interface Box {
x: number;
y: number;
@@ -9,6 +13,7 @@ export interface Box {
h: number;
}
/** Какие из четырёх дверей есть у комнаты. */
export interface Doors {
up: boolean;
down: boolean;
+28
View File
@@ -0,0 +1,28 @@
/** Маленькие чистые математические утилиты без состояния. */
import type { Box } from './types';
/** Евклидова дистанция между точками. */
export function dist(x1: number, y1: number, x2: number, y2: number): number {
return Math.hypot(x2 - x1, y2 - y1);
}
/** Пересекаются ли два прямоугольника (AABB). */
export function overlap(a: Box, b: Box): boolean {
return (
a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y
);
}
/** Ограничить значение отрезком [min, max]. */
export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
/** Линейная интерполяция (для плавной отрисовки между шагами). */
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
+34
View File
@@ -0,0 +1,34 @@
import type { RoomType, Doors } from '../types';
import { buildTiles } from './tiles';
import type { Enemy } from '../entities/Enemy';
import type { Projectile } from '../entities/Projectile';
/**
* Комната дандженa. Хранит свой тип, набор дверей, состояние «зачищена/
* посещена» и живущие в ней сущности. Двери в тайлах появляются только
* когда комната зачищена (или это спавн) — пока враги живы, выходы закрыты.
*/
export class Room {
readonly c: number;
readonly r: number;
type: RoomType;
doors: Doors = { up: false, down: false, left: false, right: false };
visited = false;
cleared = false;
enemies: Enemy[] = [];
tears: Projectile[] = [];
tiles: number[][];
constructor(c: number, r: number, type: RoomType) {
this.c = c;
this.r = r;
this.type = type;
this.tiles = buildTiles();
}
/** Перестроить тайлы; двери прорезаются, если комната зачищена или это спавн. */
rebuildTiles(): void {
const showDoors = this.cleared || this.type === 'spawn';
this.tiles = buildTiles(showDoors ? this.doors : undefined);
}
}
+103
View File
@@ -0,0 +1,103 @@
import { OPP, SPAWN } from '../../config';
import { Room } from './Room';
import type { Dir, RoomType } from '../types';
import type { Rng } from '../rng';
import { DEFAULT_RULES, type LevelRules } from '../rules';
/**
* Карта комнат: связный набор комнат на сетке (2*MAP_RADIUS+1)².
* Генерируется случайным блужданием СРАЗУ в конструкторе — поэтому
* `new RoomMap(rng)` всегда даёт готовую карту (раньше тут терялся вызов
* generate(), и игра падала на пустой карте).
*/
export class RoomMap {
readonly rooms = new Map<string, Room>();
constructor(rng: Rng, private readonly rules: LevelRules = DEFAULT_RULES) {
this.generate(rng);
}
private key(c: number, r: number): string {
return c + ',' + r;
}
get(c: number, r: number): Room | undefined {
return this.rooms.get(this.key(c, r));
}
has(c: number, r: number): boolean {
return this.rooms.has(this.key(c, r));
}
private add(c: number, r: number, type: RoomType): Room {
const room = new Room(c, r, type);
this.rooms.set(this.key(c, r), room);
return room;
}
private hasBoss(): boolean {
for (const room of this.rooms.values()) {
if (room.type === 'boss') return true;
}
return false;
}
private generate(rng: Rng): void {
this.add(0, 0, 'spawn');
const { minRooms, extraRooms, mapRadius } = this.rules.map;
const frontier: Array<[number, number]> = [[0, 0]];
let count = 1;
const target = minRooms + rng.int(0, extraRooms);
const dirs: Array<[Dir, number, number]> = [
['up', 0, -1],
['down', 0, 1],
['left', -1, 0],
['right', 1, 0],
];
while (frontier.length > 0 && count < target) {
const idx = rng.int(0, frontier.length - 1);
const [c, r] = frontier[idx];
rng.shuffle(dirs);
let added = false;
for (const [dir, dc, dr] of dirs) {
if (count >= target) break;
const nc = c + dc;
const nr = r + dr;
if (Math.abs(nc) > mapRadius || Math.abs(nr) > mapRadius) continue;
if (this.has(nc, nr)) continue;
let type: RoomType = 'normal';
if (!this.hasBoss() && (count === target - 1 || (rng.chance(SPAWN.bossChance) && count >= 3))) {
type = 'boss';
} else if (rng.chance(SPAWN.treasureChance) && count >= 2) {
type = 'treasure';
}
this.add(nc, nr, type);
// Открываем дверь у текущей комнаты и ВСТРЕЧНУЮ дверь у соседа.
this.get(c, r)!.doors[dir] = true;
this.get(nc, nr)!.doors[OPP[dir]] = true;
frontier.push([nc, nr]);
count++;
added = true;
}
if (!added) frontier.splice(idx, 1);
}
// Страховка: если босс почему-то не появился — назначаем им любую
// не-спавновую комнату.
if (!this.hasBoss()) {
const candidates = [...this.rooms.values()].filter((rm) => rm.type !== 'spawn');
if (candidates.length > 0) {
candidates[rng.int(0, candidates.length - 1)].type = 'boss';
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import { T_WALL, T_FLOOR, T_DOOR, COLS, ROWS, DOOR } from '../../config';
import type { Doors } from '../types';
/**
* Строит свежую сетку тайлов комнаты: по краям стены, внутри пол.
* Если передан doorState — в стенах прорезаются дверные проёмы.
*/
export function buildTiles(doorState?: Doors): number[][] {
const tiles: number[][] = [];
for (let r = 0; r < ROWS; r++) {
tiles[r] = [];
for (let c = 0; c < COLS; c++) {
const isEdge = r === 0 || r === ROWS - 1 || c === 0 || c === COLS - 1;
tiles[r][c] = isEdge ? T_WALL : T_FLOOR;
}
}
if (doorState) placeDoors(tiles, doorState);
return tiles;
}
/** Помечает дверные тайлы на готовой сетке согласно набору открытых дверей. */
export function placeDoors(tiles: number[][], doors: Doors): void {
if (doors.up) for (const c of DOOR.up.cols) tiles[DOOR.up.row][c] = T_DOOR;
if (doors.down) for (const c of DOOR.down.cols) tiles[DOOR.down.row][c] = T_DOOR;
if (doors.left) for (const r of DOOR.left.rows) tiles[r][DOOR.left.col] = T_DOOR;
if (doors.right) for (const r of DOOR.right.rows) tiles[r][DOOR.right.col] = T_DOOR;
}
-15
View File
@@ -1,15 +0,0 @@
import { DOOR, DIR, OPP } from './constants';
import type { Dir, Doors } from './types';
export { DOOR, DIR, OPP };
export type { Dir, Doors };
/** Return the direction opposite to the given one */
export function oppositeDir(d: Dir): Dir {
return OPP[d] as Dir;
}
/** Return the [dc, dr] offset for a direction */
export function dirOffset(d: Dir): [number, number] {
return DIR[d];
}
+63
View File
@@ -0,0 +1,63 @@
import { FIXED_DT, GAME_LOOP } from '../config';
import type { Game } from '../core/Game';
import type { InputSource } from '../input/InputState';
/**
* Игровой цикл с ФИКСИРОВАННЫМ шагом.
*
* Почему так: старый код двигал всё прямо в requestAnimationFrame, поэтому
* скорость зависела от частоты монитора — на 144 Гц игра летела в 2.4 раза
* быстрее. Здесь логика всегда обновляется ровно 60 раз в секунду (накопитель
* времени), а рендер рисует с интерполяцией. Игра идёт одинаково везде.
*/
export class GameLoop {
private accumulator = 0;
private last = 0;
private rafId = 0;
private running = false;
constructor(
private readonly game: Game,
private readonly controller: InputSource,
private readonly onRender: (alpha: number) => void,
) {}
start(): void {
if (this.running) return;
this.running = true;
this.last = performance.now();
this.rafId = requestAnimationFrame(this.frame);
}
stop(): void {
if (!this.running) return;
this.running = false;
cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
private frame = (now: number): void => {
if (!this.running) return;
this.rafId = requestAnimationFrame(this.frame);
let frameTime = (now - this.last) / 1000;
this.last = now;
if (frameTime > GAME_LOOP.maxFrameTimeSec) frameTime = GAME_LOOP.maxFrameTimeSec;
// Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр.
const input = this.controller.poll();
this.game.consumeActions(input);
this.accumulator += frameTime;
let steps = 0;
while (this.accumulator >= FIXED_DT && steps < GAME_LOOP.maxStepsPerFrame) {
this.game.step(input);
this.accumulator -= FIXED_DT;
steps++;
}
if (steps === GAME_LOOP.maxStepsPerFrame) this.accumulator = 0; // отстали — ресинхронизируемся
const alpha = this.accumulator / FIXED_DT;
this.onRender(alpha);
};
}
-54
View File
@@ -1,54 +0,0 @@
import { rand } from '../math';
import type { Box } from '../types';
export type EnemyType = 'normal' | 'fast' | 'boss';
/** Stats table indexed by enemy type */
const STATS: Record<EnemyType, { w: number; hp: number; speed: number; damage: number }> = {
normal: { w: 32, hp: 3, speed: 1.15, damage: 1 },
fast: { w: 26, hp: 2, speed: 1.9, damage: 1 },
boss: { w: 46, hp: 10, speed: 0.9, damage: 2 },
};
export class Enemy {
x: number;
y: number;
type: EnemyType;
w: number;
h: number;
hp: number;
maxHp: number;
speed: number;
damage: number;
knx = 0;
kny = 0;
hitTimer = 0;
atkTimer = 0;
constructor(x: number, y: number, type: EnemyType) {
this.x = x;
this.y = y;
this.type = type;
const s = STATS[type];
this.w = s.w;
this.h = s.w;
this.hp = s.hp;
this.maxHp = s.hp;
this.speed = s.speed;
this.damage = s.damage;
}
get box(): Box {
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
}
get alive(): boolean {
return this.hp > 0;
}
}
/** Pick a random enemy type, weighted */
export function randomEnemyType(bossRoom: boolean): EnemyType {
if (bossRoom) return 'boss';
return Math.random() < 0.3 ? 'fast' : 'normal';
}
-22
View File
@@ -1,22 +0,0 @@
import { MODE_RANGED, MODE_MELEE } from '../constants';
import type { CombatMode, Box, Dir } from '../types';
export class Player {
x = 0;
y = 0;
w = 26;
h = 26;
speed = 3.2;
hp = 6;
maxHp = 6;
mode: CombatMode = MODE_RANGED;
facing: Dir = 'up';
moveDir: Dir = 'up';
atkCD = 0;
invTimer = 0;
transCD = 0;
get box(): Box {
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
}
}
-21
View File
@@ -1,21 +0,0 @@
export class Tear {
x: number;
y: number;
dx: number;
dy: number;
r = 5;
speed = 7;
damage = 1;
life = 80;
constructor(x: number, y: number, dx: number, dy: number) {
this.x = x;
this.y = y;
this.dx = dx;
this.dy = dy;
}
get alive(): boolean {
return this.life > 0;
}
}
-334
View File
@@ -1,334 +0,0 @@
import { CW, CH, OX, OY, TILE, COLS, ROWS, DIR, DOOR } from '../constants';
import { T_WALL } from '../room/tiles';
import { MODE_RANGED, MODE_MELEE } from '../constants';
import type { Dir } from '../types';
import { KEYS } from '../input';
import { overlap } from '../math';
import { RoomMap } from '../room/RoomMap';
import { Room } from '../room/Room';
import { Player } from '../entities/Player';
import { Enemy } from '../entities/Enemy';
import { Tear } from '../entities/Tear';
import { MeleeSwing } from '../entities/MeleeSwing';
import { collidesWall } from './collision';
import { checkTransition } from './transitions';
import { drawRoom } from '../render/roomRenderer';
import { drawEntities } from '../render/entityRenderer';
import { drawHUD } from '../render/hudRenderer';
import { drawMinimap } from '../render/minimapRenderer';
export class Game {
// Canvas
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
// World
roomMap = new RoomMap();
player = new Player();
cc = 0;
cr = 0;
meleeSwing: MeleeSwing | null = null;
// State
gameOver = false;
won = false;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.enterRoom('up');
this.loop();
}
get curRoom(): Room {
return this.roomMap.get(this.cc, this.cr)!;
}
toggleMode(): void {
this.player.mode = this.player.mode === MODE_RANGED ? MODE_MELEE : MODE_RANGED;
}
restart(): void {
this.gameOver = false;
this.won = false;
this.roomMap = new RoomMap();
this.player = new Player();
this.cc = 0;
this.cr = 0;
this.meleeSwing = null;
this.enterRoom('up');
}
/** Place the player inside the current room after entering via a door */
enterRoom(fromDir: Dir): void {
const room = this.curRoom;
room.visited = true;
const d = DOOR[fromDir];
const [ddc, ddr] = DIR[fromDir];
this.player.x = OX + d.cx * TILE + TILE / 2 - ddc * TILE;
this.player.y = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
this.player.facing = fromDir;
this.player.invTimer = 20;
this.player.transCD = 15;
this.meleeSwing = null;
room.buildTiles();
room.enemies = [];
room.tears = [];
if (!room.cleared && room.type !== 'spawn') {
this.spawnEnemies(room, fromDir);
} else {
room.cleared = true;
room.buildTiles();
}
}
// -------- SPAWNING --------
private spawnEnemies(room: Room, entryDir: Dir): void {
const count = room.type === 'boss' ? 1 : room.type === 'treasure' ? 0 : 2 + Math.floor(Math.random() * 3);
for (let i = 0; i < count; i++) {
let tries = 0;
let x: number, y: number, ok: boolean;
const type = room.type === 'boss' ? 'boss' as const
: Math.random() < 0.3 ? 'fast' as const : 'normal' as const;
do {
x = OX + 2 * TILE + Math.random() * (COLS - 4) * TILE;
y = OY + 2 * TILE + Math.random() * (ROWS - 4) * TILE;
ok = true;
const ed = DOOR[entryDir];
const dx = OX + ed.cx * TILE + TILE / 2;
const dy = OY + ed.cy * TILE + TILE / 2;
if (Math.hypot(x - dx, y - dy) < 180) ok = false;
for (const e of room.enemies) {
if (Math.hypot(x - e.x, y - e.y) < 60) { ok = false; break; }
}
if (Math.hypot(x - this.player.x, y - this.player.y) < 150) ok = false;
tries++;
} while (!ok && tries < 100);
room.enemies.push(new Enemy(x, y, type));
}
}
// -------- GAME LOOP --------
private loop(): void {
if (!this.gameOver && !this.won) this.tick();
this.render();
requestAnimationFrame(() => this.loop());
}
private tick(): void {
const room = this.curRoom;
const p = this.player;
if (p.invTimer > 0) p.invTimer--;
if (p.atkCD > 0) p.atkCD--;
if (p.transCD > 0) p.transCD--;
this.processMovement(p);
this.processAttack(room, p);
this.updateMelee(room);
this.updateTears(room);
const aliveCount = this.updateEnemies(room, p);
if (room.enemies.length > 0 && aliveCount === 0 && !room.cleared) {
room.cleared = true;
room.buildTiles();
}
checkTransition(this);
if (!this.gameOver) {
const bossRoom = [...this.roomMap.rooms.values()].find(r => r.type === 'boss');
if (bossRoom?.cleared) this.won = true;
}
}
private processMovement(p: Player): void {
let mx = 0, my = 0;
if (KEYS['w'] || KEYS['W']) my = -1;
if (KEYS['s'] || KEYS['S']) my = 1;
if (KEYS['a'] || KEYS['A']) mx = -1;
if (KEYS['d'] || KEYS['D']) mx = 1;
if (mx !== 0 || my !== 0) {
const len = Math.hypot(mx, my);
mx /= len;
my /= len;
if (my < 0) p.moveDir = 'up';
else if (my > 0) p.moveDir = 'down';
if (mx < 0) p.moveDir = 'left';
else if (mx > 0) p.moveDir = 'right';
const dx = mx * p.speed;
const dy = my * p.speed;
p.x += dx;
if (collidesWall(p.box, this.curRoom, OX, OY)) p.x -= dx;
p.y += dy;
if (collidesWall(p.box, this.curRoom, OX, OY)) p.y -= dy;
}
}
private processAttack(room: Room, p: Player): void {
let ax = 0, ay = 0;
if (KEYS['ArrowUp']) { ax = 0; ay = -1; }
else if (KEYS['ArrowDown']) { ax = 0; ay = 1; }
else if (KEYS['ArrowLeft']) { ax = -1; ay = 0; }
else if (KEYS['ArrowRight']) { ax = 1; ay = 0; }
else if (KEYS[' '] || KEYS['Space']) {
[ax, ay] = DIR[p.moveDir];
}
if ((ax !== 0 || ay !== 0) && p.atkCD <= 0) {
const len = Math.hypot(ax, ay);
ax /= len;
ay /= len;
const dn: Dir = ay < 0 ? 'up' : ay > 0 ? 'down' : ax < 0 ? 'left' : 'right';
p.facing = dn;
p.atkCD = p.mode === MODE_RANGED ? 10 : 22;
if (p.mode === MODE_RANGED) {
room.tears.push(new Tear(p.x, p.y, ax, ay));
} else {
this.meleeSwing = new MeleeSwing(p.x, p.y, dn);
}
}
}
private updateMelee(room: Room): void {
if (this.meleeSwing && !this.meleeSwing.alive) this.meleeSwing = null;
if (!this.meleeSwing) return;
this.meleeSwing.life--;
for (const e of room.enemies) {
if (!e.alive || e.hitTimer > 0) continue;
if (overlap(e.box, this.meleeSwing.box)) {
e.hp -= this.meleeSwing.damage;
e.hitTimer = 10;
const [dx, dy] = DIR[this.meleeSwing.dir];
e.knx = dx * this.meleeSwing.kb;
e.kny = dy * this.meleeSwing.kb;
}
}
}
private updateTears(room: Room): void {
for (const t of room.tears) {
if (!t.alive) continue;
t.x += t.dx * t.speed;
t.y += t.dy * t.speed;
t.life--;
const col = Math.floor((t.x - OX) / TILE);
const row = Math.floor((t.y - OY) / TILE);
if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) {
t.life = 0;
continue;
}
if (room.tiles[row][col] === T_WALL) {
t.life = 0;
continue;
}
for (const e of room.enemies) {
if (!e.alive) continue;
if (Math.hypot(t.x - e.x, t.y - e.y) < e.w / 2 + t.r) {
e.hp -= t.damage;
e.hitTimer = 8;
t.life = 0;
break;
}
}
}
room.tears = room.tears.filter(t => t.alive);
}
private updateEnemies(room: Room, p: Player): number {
let aliveCount = 0;
for (const e of room.enemies) {
if (!e.alive) continue;
aliveCount++;
if (e.hitTimer > 0) e.hitTimer--;
if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) {
e.x += e.knx * 3;
e.y += e.kny * 3;
e.knx *= 0.85;
e.kny *= 0.85;
continue;
}
e.knx = 0;
e.kny = 0;
const dx = p.x - e.x;
const dy = p.y - e.y;
const d = Math.hypot(dx, dy);
if (d > 0 && d < 500) {
const s = e.speed;
const mx = (dx / d) * s;
const my = (dy / d) * s;
e.x += mx;
if (collidesWall(e.box, room, OX, OY)) e.x -= mx;
e.y += my;
if (collidesWall(e.box, room, OX, OY)) e.y -= my;
}
if (e.atkTimer > 0) e.atkTimer--;
if (Math.hypot(e.x - p.x, e.y - p.y) < (e.w + p.w) / 2 && p.invTimer <= 0 && e.atkTimer <= 0) {
p.hp -= e.damage;
p.invTimer = 60;
e.atkTimer = 30;
if (p.hp <= 0) {
this.gameOver = true;
return aliveCount;
}
}
}
return aliveCount;
}
// -------- RENDERING --------
private render(): void {
const ctx = this.ctx;
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, CW, CH);
drawRoom(ctx, this.curRoom);
drawEntities(ctx, this.curRoom, this.player, this.meleeSwing);
drawHUD(ctx, this.player, this.curRoom);
drawMinimap(ctx, this.roomMap, this.cc, this.cr);
if (this.gameOver) this.drawOverlay('#c33', 'GAME OVER');
else if (this.won) this.drawOverlay('#3c3', 'VICTORY');
}
private drawOverlay(color: string, text: string): void {
const ctx = this.ctx;
ctx.fillStyle = 'rgba(0,0,0,0.8)';
ctx.fillRect(0, 0, CW, CH);
ctx.fillStyle = color;
ctx.font = 'bold 56px monospace';
ctx.textAlign = 'center';
ctx.fillText(text, CW / 2, CH / 2 - 20);
ctx.fillStyle = '#888';
ctx.font = '18px monospace';
ctx.fillText('[R] restart', CW / 2, CH / 2 + 40);
}
}
-33
View File
@@ -1,33 +0,0 @@
import { ROWS, COLS, DOOR } from '../constants';
import { T_WALL } from '../room/tiles';
import type { Room } from '../room/Room';
import type { Box } from '../types';
/** Check if a given tile is blocked for movement */
export function isBlocked(room: Room, col: number, row: number): boolean {
// Allow passing through the room boundary at door openings
if (row < 0 && room.doors.up && DOOR.up.cols.includes(col)) return false;
if (row >= ROWS && room.doors.down && DOOR.down.cols.includes(col)) return false;
if (col < 0 && room.doors.left && DOOR.left.rows.includes(row)) return false;
if (col >= COLS && room.doors.right && DOOR.right.rows.includes(row)) return false;
if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return true;
return room.tiles[row][col] === T_WALL;
}
/** Test whether an entity's bounding box overlaps any wall tile */
export function collidesWall(box: Box, room: Room, ox: number, oy: number): boolean {
const l = Math.floor((box.x - ox) / TILE_SIZE);
const r = Math.floor((box.x + box.w - ox) / TILE_SIZE);
const t = Math.floor((box.y - oy) / TILE_SIZE);
const b = Math.floor((box.y + box.h - oy) / TILE_SIZE);
for (let row = t; row <= b; row++) {
for (let col = l; col <= r; col++) {
if (isBlocked(room, col, row)) return true;
}
}
return false;
}
const TILE_SIZE = 44; // matches TILE in constants — duplicated to avoid circular dep
-72
View File
@@ -1,72 +0,0 @@
import { OX, OY, TILE, COLS, ROWS, DOOR } from '../constants';
import type { Dir } from '../types';
import type { Game } from './Game';
import { KEYS } from '../input';
/** Entry direction → movement keys that trigger a transition */
const ENTRY_KEYS: Record<string, string[]> = {
up: ['w', 'W', 'ArrowUp'],
down: ['s', 'S', 'ArrowDown'],
left: ['a', 'A', 'ArrowLeft'],
right: ['d', 'D', 'ArrowRight'],
};
function keyPressed(dir: Dir): boolean {
for (const k of ENTRY_KEYS[dir]) {
if (KEYS[k]) return true;
}
return false;
}
/**
* Called every frame from Game.tick().
* If the player stands on a cleared room's door tile and presses
* the matching movement key, transition into the adjacent room.
*/
export function checkTransition(game: Game): void {
if (game.gameOver || game.won) return;
if (game.player.transCD > 0) return;
const p = game.player;
const room = game.curRoom;
if (!room.cleared) return;
const col = Math.floor((p.x - OX) / TILE);
const row = Math.floor((p.y - OY) / TILE);
// Top door
if (row === 0 && room.doors.up && DOOR.up.cols.includes(col) && keyPressed('up')) {
if (game.roomMap.has(game.cc, game.cr - 1)) {
game.cr--;
game.enterRoom('down');
return;
}
}
// Bottom door
if (row === ROWS - 1 && room.doors.down && DOOR.down.cols.includes(col) && keyPressed('down')) {
if (game.roomMap.has(game.cc, game.cr + 1)) {
game.cr++;
game.enterRoom('up');
return;
}
}
// Left door
if (col === 0 && room.doors.left && DOOR.left.rows.includes(row) && keyPressed('left')) {
if (game.roomMap.has(game.cc - 1, game.cr)) {
game.cc--;
game.enterRoom('right');
return;
}
}
// Right door
if (col === COLS - 1 && room.doors.right && DOOR.right.rows.includes(row) && keyPressed('right')) {
if (game.roomMap.has(game.cc + 1, game.cr)) {
game.cc++;
game.enterRoom('left');
return;
}
}
}
-17
View File
@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dungeon Crawl — Ranged / Melee</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
</style>
</head>
<body>
<canvas id="game" width="880" height="660"></canvas>
<script src="main.js"></script>
</body>
</html>
-18
View File
@@ -1,18 +0,0 @@
// Global keyboard state (shared mutable map)
export const KEYS: Record<string, boolean> = {};
export function setupInput(): void {
window.addEventListener('keydown', (e: KeyboardEvent) => {
KEYS[e.key] = true;
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(e.key)) {
e.preventDefault();
}
});
window.addEventListener('keyup', (e: KeyboardEvent) => {
KEYS[e.key] = false;
});
window.addEventListener('blur', () => {
// Reset all keys on window blur to avoid stuck keys
for (const k in KEYS) delete KEYS[k];
});
}
+47
View File
@@ -0,0 +1,47 @@
import type { Dir } from '../core/types';
/**
* Снимок намерений игрока за один опрос ввода — абстракция над «железом».
* Игровая логика (core/Game.ts) читает ТОЛЬКО это, ничего не зная про
* клавиатуру. Захочешь геймпад или сенсор — просто сделай ещё один
* контроллер, отдающий такой же InputState.
*
* Поля делятся на два вида:
* • удерживаемые (move*, aimDir, attackHeld) — читаются каждый шаг симуляции;
* • однократные «edge» (toggleWeapon, restart) — срабатывают один раз на нажатие.
*/
export interface InputState {
moveX: number; // -1 влево, +1 вправо, 0 нет
moveY: number; // -1 вверх, +1 вниз, 0 нет
aimDir: Dir | null; // прицеливание стрелками (приоритетнее attackHeld)
attackHeld: boolean; // атака «по ходу движения» (пробел)
toggleWeapon: boolean; // сменить оружие (однократно)
restart: boolean; // рестарт на экране конца игры (однократно)
}
/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */
export interface InputSource {
poll(): InputState;
}
/** Нейтральный снимок — ничего не нажато. */
export function emptyInput(): InputState {
return {
moveX: 0,
moveY: 0,
aimDir: null,
attackHeld: false,
toggleWeapon: false,
restart: false,
};
}
/** Жмёт ли игрок в сторону dir (движением ИЛИ прицеливанием) — для переходов. */
export function pressingDir(input: InputState, dir: Dir): boolean {
switch (dir) {
case 'up': return input.moveY < 0 || input.aimDir === 'up';
case 'down': return input.moveY > 0 || input.aimDir === 'down';
case 'left': return input.moveX < 0 || input.aimDir === 'left';
case 'right': return input.moveX > 0 || input.aimDir === 'right';
}
}
+98
View File
@@ -0,0 +1,98 @@
import type { InputSource, InputState } from './InputState';
import type { Dir } from '../core/types';
/**
* Раскладка: WASD — движение, стрелки — прицельная стрельба, пробел —
* атака по ходу движения, Tab/Q — смена оружия, R — рестарт.
*
* ВАЖНО: используем `event.code` (ФИЗИЧЕСКАЯ клавиша), а не `event.key`.
* `code` не зависит от раскладки, поэтому WASD/Q/R работают и в русской
* раскладке (где те же клавиши дают «цфыв»/«й»/«к»). Стрелки/Tab/пробел в
* `code` называются ArrowUp/Tab/Space.
*
* Контроллер держит набор зажатых клавиш и «защёлкивает» однократные действия
* (смена оружия/рестарт). Раз в кадр вызывается poll(), который собирает
* InputState и сбрасывает однократные флаги.
*/
export class KeyboardController implements InputSource {
private held = new Set<string>();
private toggleWeaponEdge = false;
private restartEdge = false;
private attached = false;
private onKeyDown = (e: KeyboardEvent): void => {
const c = e.code;
// Однократные действия ловим по факту нажатия (не по удержанию).
if (!this.held.has(c)) {
if (c === 'Tab' || c === 'KeyQ') this.toggleWeaponEdge = true;
if (c === 'KeyR') this.restartEdge = true;
}
this.held.add(c);
if (PREVENT.has(c)) e.preventDefault();
};
private onKeyUp = (e: KeyboardEvent): void => {
this.held.delete(e.code);
};
private onBlur = (): void => {
// Сбрасываем всё, чтобы клавиши не «залипали» при потере фокуса.
this.held.clear();
};
/**
* Сбросить весь ввод: зажатые клавиши и однократные действия. Звать на
* границе забега (старт новой игры) — иначе клавиша, зажатая в прошлом
* забеге, или залатченная смена оружия «перетекут» в новый.
*/
reset(): void {
this.held.clear();
this.toggleWeaponEdge = false;
this.restartEdge = false;
}
/** Подписаться на события окна. Вызывается один раз при старте. */
attach(target: Window = window): void {
if (this.attached) return;
target.addEventListener('keydown', this.onKeyDown);
target.addEventListener('keyup', this.onKeyUp);
target.addEventListener('blur', this.onBlur);
this.attached = true;
}
/** Собрать снимок ввода и сбросить однократные флаги. */
poll(): InputState {
const down = (code: string) => this.held.has(code);
let moveX = 0;
let moveY = 0;
if (down('KeyW')) moveY -= 1;
if (down('KeyS')) moveY += 1;
if (down('KeyA')) moveX -= 1;
if (down('KeyD')) moveX += 1;
let aimDir: Dir | null = null;
if (down('ArrowUp')) aimDir = 'up';
else if (down('ArrowDown')) aimDir = 'down';
else if (down('ArrowLeft')) aimDir = 'left';
else if (down('ArrowRight')) aimDir = 'right';
const snapshot: InputState = {
moveX,
moveY,
aimDir,
attackHeld: down('Space'),
toggleWeapon: this.toggleWeaponEdge,
restart: this.restartEdge,
};
this.toggleWeaponEdge = false;
this.restartEdge = false;
return snapshot;
}
}
/** Физические клавиши (e.code), у которых гасим поведение браузера (скролл/таб). */
const PREVENT = new Set([
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab',
]);
+68 -29
View File
@@ -1,32 +1,71 @@
import { setupInput, KEYS } from './input';
import { Game } from './game/Game';
/**
* main.ts — точка входа и «склейка». Поток:
* стартовое меню → выбор уровня (правил) → создаём Game с этими правилами →
* запускаем цикл. Esc во время игры — назад в меню.
*
* Это единственное место, где встречаются логика, рендер и ввод — поэтому
* именно здесь проще всего подменить рендер/ввод или добавить экраны.
*/
import { Game } from './core/Game';
import { PRESETS, type LevelRules } from './core/rules';
import { KeyboardController } from './input/KeyboardController';
import { ThreeRenderer } from './render/ThreeRenderer';
import { HudOverlay } from './render/HudOverlay';
import { GameLoop } from './engine/GameLoop';
import { StartMenu } from './ui/StartMenu';
// Global key bindings (mode toggle, restart) handled here
setupInput();
window.addEventListener('keydown', (e: KeyboardEvent) => {
const game = (window as any).__game as Game | undefined;
if (!game) return;
// Toggle combat mode
if ((e.key === 'Tab' || e.key === 'q' || e.key === 'Q') && !game.gameOver && !game.won) {
e.preventDefault();
game.toggleMode();
}
// Restart
if (e.key === 'r' || e.key === 'R') {
if (game.gameOver || game.won) game.restart();
}
});
// Bootstrap
window.addEventListener('load', () => {
const canvas = document.getElementById('game') as HTMLCanvasElement;
if (!canvas) {
document.body.innerHTML = '<p style="color:red">Error: canvas element not found</p>';
function boot(): void {
const world = document.getElementById('game') as HTMLCanvasElement | null;
const hudCanvas = document.getElementById('hud') as HTMLCanvasElement | null;
const menuEl = document.getElementById('menu');
if (!world || !hudCanvas || !menuEl) {
document.body.innerHTML =
'<p style="color:#c33;font-family:monospace;padding:2rem">Ошибка: не найдены #game / #hud / #menu в разметке.</p>';
return;
}
const game = new Game(canvas);
(window as any).__game = game;
});
// Рендер и ввод создаём один раз — они переиспользуются между забегами.
const controller = new KeyboardController();
const world3d = new ThreeRenderer(world);
const hud = new HudOverlay(hudCanvas);
controller.attach();
let loop: GameLoop | null = null;
const startGame = (rules: LevelRules): void => {
loop?.stop();
controller.reset(); // чистый ввод: не тащим зажатые клавиши/смену оружия из прошлого забега
const game = new Game(rules);
loop = new GameLoop(game, controller, (alpha) => {
world3d.render(game, alpha);
hud.render(game);
});
menu.hide();
loop.start();
// Debug-хэндл: в консоли браузера доступен `game`.
(window as Window & { game?: Game }).game = game;
};
const toMenu = (): void => {
loop?.stop();
loop = null;
menu.show(); // фон меню перекрывает «замёрзший» последний кадр
};
const menu = new StartMenu(menuEl, PRESETS, startGame);
menu.show();
// Esc во время игры — вернуться к выбору уровня (по физической клавише).
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape' && loop) {
e.preventDefault();
toMenu();
}
});
}
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
-30
View File
@@ -1,30 +0,0 @@
/** Fisher-Yates shuffle (mutates array in place) */
export function shuffle<T>(a: T[]): T[] {
for (let i = a.length - 1; i > 0; i--) {
const j = (Math.random() * i) | 0;
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/** Random float in [a, b) */
export function rand(a: number, b: number): number {
return Math.random() * (b - a) + a;
}
/** Random integer in [a, b] inclusive */
export function ri(a: number, b: number): number {
return Math.floor(rand(a, b + 1));
}
/** Euclidean distance */
export function dist(x1: number, y1: number, x2: number, y2: number): number {
return Math.hypot(x2 - x1, y2 - y1);
}
/** Axis-aligned bounding box overlap test */
export function overlap(a: { x: number; y: number; w: number; h: number },
b: { x: number; y: number; w: number; h: number }): boolean {
return a.x < b.x + b.w && a.x + a.w > b.x &&
a.y < b.y + b.h && a.y + a.h > b.y;
}
+170
View File
@@ -0,0 +1,170 @@
import { CW, CH, OY, RH, MODE_RANGED } from '../config';
import type { Game } from '../core/Game';
import type { Renderer } from './Renderer';
/**
* HUD и миникарта на прозрачном 2D-канвасе ПОВЕРХ WebGL-холста.
* Текст и тонкие линии в Canvas2D остаются чёткими и их просто стилизовать —
* куда удобнее, чем тянуть шрифты в WebGL. Чисто отрисовка, без логики.
*/
export class HudOverlay implements Renderer {
private readonly ctx: CanvasRenderingContext2D;
private readonly images = new Map<string, HTMLImageElement>();
/** Лениво грузит PNG из assets/<name>.png; возвращает картинку, только когда она готова. */
private img(name: string): HTMLImageElement | null {
let im = this.images.get(name);
if (!im) {
im = new Image();
im.src = `assets/${name}.png`;
this.images.set(name, im);
}
return im.complete && im.naturalWidth > 0 ? im : null;
}
constructor(canvas: HTMLCanvasElement) {
// Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI),
// а рисуем по-прежнему в логических координатах CW×CH.
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = CW * dpr;
canvas.height = CH * dpr;
this.ctx = canvas.getContext('2d')!;
this.ctx.scale(dpr, dpr);
}
render(game: Game): void {
const ctx = this.ctx;
ctx.clearRect(0, 0, CW, CH);
this.drawHud(game);
this.drawMinimap(game);
if (game.gameOver) this.drawOverlay('#c33', 'ИГРА ОКОНЧЕНА');
else if (game.won) this.drawOverlay('#3c3', 'ПОБЕДА');
}
dispose(): void {
this.ctx.clearRect(0, 0, CW, CH);
}
private drawHud(game: Game): void {
const ctx = this.ctx;
const p = game.player;
const room = game.curRoom;
// Здоровье: сердечки (2 HP = сердце), с фолбэком на полосу.
const healthBottom = this.drawHealth(p.hp, p.maxHp);
// Название текущего уровня (правил).
ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace';
ctx.fillText(`Уровень: ${game.rules.name}`, 20, healthBottom + 14);
// Индикатор режима боя.
const my = CH - 46;
const ranged = p.mode === MODE_RANGED;
const mText = ranged ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ';
const mCol = ranged ? '#4488cc' : '#cc6644';
ctx.textAlign = 'center';
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
ctx.strokeStyle = mCol; ctx.lineWidth = 2; ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
ctx.fillStyle = mCol; ctx.font = 'bold 17px monospace'; ctx.fillText(`[ ${mText} ]`, CW / 2, my + 8);
ctx.fillStyle = '#555'; ctx.font = '11px monospace'; ctx.fillText('[Tab] сменить оружие', CW / 2, my - 26);
// Иконка оружия слева в рамке (если ассет есть).
const icon = this.img(ranged ? 'icon-ranged' : 'icon-melee');
if (icon) ctx.drawImage(icon, CW / 2 - 90, my - 14, 26, 26);
// Счётчик врагов / подсказка зачистки.
ctx.textAlign = 'left';
const alive = room.enemies.filter((e) => e.alive).length;
if (alive > 0) {
ctx.fillStyle = '#aa4444'; ctx.font = '13px monospace';
ctx.fillText(`${alive}`, 20, CH - 18);
} else if (!room.cleared && room.type !== 'spawn') {
ctx.fillStyle = '#886633'; ctx.font = '13px monospace';
ctx.fillText('Зачисти комнату', 20, CH - 18);
}
// Подпись типа комнаты.
if (room.visited) {
const label = { spawn: 'СТАРТ', normal: '', treasure: 'СОКРОВИЩЕ', boss: 'БОСС' }[room.type];
if (label) {
ctx.textAlign = 'right'; ctx.fillStyle = '#555'; ctx.font = '11px monospace';
ctx.fillText(label, CW - 20, OY + RH + 30);
}
}
}
/** Рисует здоровье сердечками (2 HP = сердце); фолбэк — полоса. Возвращает нижний Y. */
private drawHealth(hp: number, maxHp: number): number {
const ctx = this.ctx;
const x0 = 20, y0 = 16;
const full = this.img('heart-full');
const half = this.img('heart-half');
const empty = this.img('heart-empty');
if (full && half && empty) {
const sz = 26, gap = 2;
const slots = Math.ceil(maxHp / 2);
for (let i = 0; i < slots; i++) {
const rem = hp - i * 2;
const im = rem >= 2 ? full : rem === 1 ? half : empty;
ctx.drawImage(im, x0 + i * (sz + gap), y0, sz, sz);
}
return y0 + sz;
}
// Фолбэк — полоса HP.
const by = 20, bw = 140, bh = 14;
ctx.fillStyle = '#111'; ctx.fillRect(x0, by, bw, bh);
ctx.fillStyle = '#2a0a0a'; ctx.fillRect(x0 + 2, by + 2, bw - 4, bh - 4);
const ratio = Math.max(0, hp / maxHp);
ctx.fillStyle = ratio > 0.5 ? '#993333' : ratio > 0.25 ? '#994422' : '#663322';
ctx.fillRect(x0 + 2, by + 2, (bw - 4) * ratio, bh - 4);
ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.strokeRect(x0, by, bw, bh);
ctx.fillStyle = '#bbb'; ctx.font = '10px monospace'; ctx.textAlign = 'center';
ctx.fillText(`HP ${hp}/${maxHp}`, x0 + bw / 2, by + bh - 3);
return by + bh;
}
private drawMinimap(game: Game): void {
const ctx = this.ctx;
const ox = CW - 180, oy = 12, cell = 14, gap = 2, cs = cell + gap;
ctx.fillStyle = 'rgba(0,0,0,0.75)'; ctx.fillRect(ox - 8, oy - 8, cs * 7 + 16, cs * 7 + 16);
ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.strokeRect(ox - 8, oy - 8, cs * 7 + 16, cs * 7 + 16);
for (let r = -3; r <= 3; r++) {
for (let c = -3; c <= 3; c++) {
const room = game.roomMap.get(game.cc + c, game.cr + r);
if (!room) continue;
const x = ox + (c + 3) * cs, y = oy + (r + 3) * cs;
let color = '#141414';
if (room.visited) {
color = { spawn: '#2a5a2a', boss: '#5a1a1a', treasure: '#5a5a1a', normal: '#555' }[room.type];
}
ctx.fillStyle = color; ctx.fillRect(x, y, cell, cell);
if (room.visited) {
ctx.fillStyle = 'rgba(255,255,255,0.5)';
if (room.doors.up) ctx.fillRect(x + cs / 2 - 2, y - 2, 4, 3);
if (room.doors.down) ctx.fillRect(x + cs / 2 - 2, y + cell - 1, 4, 3);
if (room.doors.left) ctx.fillRect(x - 2, y + cs / 2 - 2, 3, 4);
if (room.doors.right) ctx.fillRect(x + cell - 1, y + cs / 2 - 2, 3, 4);
}
if (c === 0 && r === 0) {
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 2; ctx.strokeRect(x - 1.5, y - 1.5, cell + 3, cell + 3);
}
}
}
}
private drawOverlay(color: string, text: string): void {
const ctx = this.ctx;
ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(0, 0, CW, CH);
ctx.fillStyle = color; ctx.font = 'bold 56px monospace'; ctx.textAlign = 'center';
ctx.fillText(text, CW / 2, CH / 2 - 20);
ctx.fillStyle = '#888'; ctx.font = '18px monospace';
ctx.fillText('[R] заново', CW / 2, CH / 2 + 40);
ctx.fillStyle = '#666'; ctx.font = '14px monospace';
ctx.fillText('[Esc] в меню', CW / 2, CH / 2 + 68);
}
}
+21
View File
@@ -0,0 +1,21 @@
import type { Game } from '../core/Game';
/**
* Контракт любого рендера. Игровая логика про него ничего не знает —
* рендер только ЧИТАЕТ состояние Game и рисует его.
*
* Хочешь другой рендер (чистый Canvas2D, пиксель-арт, настоящий 3D) —
* реализуй этот интерфейс и подмени в main.ts. Ядро менять не нужно.
*/
export interface Renderer {
/**
* Нарисовать кадр.
* @param game текущее состояние игры (только чтение).
* @param alpha доля времени до следующего шага [0..1) для интерполяции
* позиций между prevX/prevY и x/y (плавность на >60 Гц).
*/
render(game: Game, alpha: number): void;
/** Освободить ресурсы GPU/DOM. */
dispose(): void;
}
+375
View File
@@ -0,0 +1,375 @@
import * as THREE from 'three';
import {
CW, CH, OX, OY, TILE, COLS, ROWS, RW, RH,
DIR, MODE_RANGED, PROJECTILE, MELEE,
} from '../config';
import { lerp } from '../core/util';
import type { Game } from '../core/Game';
import type { Room } from '../core/world/Room';
import type { Enemy } from '../core/entities/Enemy';
import type { Projectile } from '../core/entities/Projectile';
import type { Renderer } from './Renderer';
import { DEFAULT_THEME, type Theme } from './theme';
import { Assets, type SpriteKey } from './assets';
/**
* Псевдо-3D рендер «как в Isaac»: наклонная перспективная камера, пол лежит
* плоско, а персонажи/враги — ВЕРТИКАЛЬНЫЕ спрайты-биллборды, стоящие на полу.
*
* КАРТА КООРДИНАТ: игровая логика остаётся 2D-сверху (x, y). В 3D мы кладём
* y на ось Z: мировая точка = (x, высота, y). Пол — плоскость Y=0; вверх — +Y.
* Поэтому ВСЯ математика и КОЛЛИЗИИ ядра без изменений: хитбоксы по-прежнему на
* полу (footprint спрайта), псевдо-3D — чисто визуальный слой.
*
* Камера фиксированная на комнату (как в Isaac), кадрирует всю комнату.
*/
// ── Параметры вида (крути для настройки картинки) ─────────────
const WALL_H = 38; // высота стен
const SPRITE_SCALE = 1.5; // ширина спрайта = размер хитбокса × это
const SPRITE_ASPECT = 64 / 48; // высота/ширина спрайта (из канваса ассета)
const SHADOW_Y = 0.6; // тень чуть над полом (без z-fighting)
const TEAR_Y = 13; // высота полёта снаряда
const GAP = 3 * TILE; // ширина дверного проёма (3 тайла)
type EnemyVisual = { sprite: THREE.Mesh; shadow: THREE.Mesh; lastHit: number };
type Effect = { mesh: THREE.Mesh; life: number; max: number; vy: number; grow: number };
export class ThreeRenderer implements Renderer {
private readonly renderer: THREE.WebGLRenderer;
private readonly scene = new THREE.Scene();
private readonly camera: THREE.PerspectiveCamera;
private readonly assets = new Assets();
private readonly theme: Theme;
// Общие геометрии.
private readonly vGeo = new THREE.PlaneGeometry(1, 1); // вертикальный спрайт/стена
private readonly flatGeo = new THREE.PlaneGeometry(1, 1); // лежит на полу (повёрнут)
// Общие материалы.
private readonly floorMat: THREE.MeshBasicMaterial;
private readonly wallMat: THREE.MeshBasicMaterial;
private readonly shadowMat: THREE.MeshBasicMaterial;
private readonly playerMat: Record<'ranged' | 'melee', THREE.MeshBasicMaterial>;
private readonly enemyMatKey: Record<Enemy['type'], SpriteKey> = {
normal: 'enemy-normal', fast: 'enemy-fast', boss: 'enemy-boss',
};
// Группа статичной геометрии комнаты (пол + стены + двери).
private roomGroup = new THREE.Group();
private renderedRoom: Room | null = null;
private renderedCleared = false;
// Динамика.
private readonly playerMesh: THREE.Mesh;
private readonly playerShadow: THREE.Mesh;
private readonly enemyVisuals = new Map<Enemy, EnemyVisual>();
private readonly tearMeshes = new Map<Projectile, THREE.Mesh>();
private readonly swingMesh: THREE.Mesh;
private readonly effects: Effect[] = [];
private lastAtkCD = 0;
constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) {
this.theme = theme;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setSize(CW, CH, false);
this.renderer.setClearColor(theme.bg, 1);
// Наклонная камера, кадрирует комнату с юга-сверху.
this.camera = new THREE.PerspectiveCamera(44, CW / CH, 1, 4000);
const cx = OX + RW / 2;
const cz = OY + RH / 2;
// Наклонный «исааковский» ракурс: камера приподнята и отодвинута на юг.
this.camera.position.set(cx, 650, cz + 560);
this.camera.lookAt(cx, 40, cz);
// Материалы.
const floorTex = this.assets.floor();
floorTex.wrapS = floorTex.wrapT = THREE.RepeatWrapping;
floorTex.repeat.set(COLS, ROWS);
this.floorMat = new THREE.MeshBasicMaterial({ map: floorTex });
const wallTex = this.assets.wall();
wallTex.wrapS = wallTex.wrapT = THREE.RepeatWrapping;
wallTex.repeat.set(4, 1);
this.wallMat = new THREE.MeshBasicMaterial({ map: wallTex, side: THREE.DoubleSide });
this.shadowMat = new THREE.MeshBasicMaterial({
map: this.assets.shadow(), transparent: true, depthWrite: false,
});
const spriteMat = (key: SpriteKey) =>
new THREE.MeshBasicMaterial({ map: this.assets.sprite(key), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide });
this.playerMat = { ranged: spriteMat('player-ranged'), melee: spriteMat('player-melee') };
this.scene.add(this.roomGroup);
this.playerShadow = this.flatMesh(this.shadowMat);
this.scene.add(this.playerShadow);
this.playerMesh = new THREE.Mesh(this.vGeo, this.playerMat.ranged);
this.scene.add(this.playerMesh);
this.swingMesh = this.flatMesh(
new THREE.MeshBasicMaterial({
map: this.assets.spark(), color: new THREE.Color(this.theme.swing),
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
}),
);
this.swingMesh.visible = false;
this.scene.add(this.swingMesh);
}
render(game: Game, alpha: number): void {
const room = game.curRoom;
if (room !== this.renderedRoom || room.cleared !== this.renderedCleared) {
this.buildRoom(room);
}
this.syncPlayer(game, alpha);
this.syncEnemies(room, alpha);
this.syncTears(room, alpha);
this.syncSwing(game);
this.updateEffects();
this.renderer.render(this.scene, this.camera);
}
// ── Статика комнаты: пол, стены, двери ────────────────────
private buildRoom(room: Room): void {
this.clearGroup(this.roomGroup);
this.renderedRoom = room;
this.renderedCleared = room.cleared;
// Пол — одна плоскость на весь периметр, ПЛАШМЯ (flatMesh кладёт её
// горизонтально; без этого пол стоял бы вертикально и перспектива «выворачивалась»).
const floor = this.flatMesh(this.floorMat);
floor.scale.set(RW, RH, 1);
floor.position.set(OX + RW / 2, 0, OY + RH / 2);
this.roomGroup.add(floor);
// Стены по сторонам с проёмами под двери.
const xGap: [number, number] | null =
room.doors.up || room.doors.down ? [OX + 6 * TILE, OX + 6 * TILE + GAP] : null;
const zGap: [number, number] | null =
room.doors.left || room.doors.right ? [OY + 4 * TILE, OY + 4 * TILE + GAP] : null;
this.addWall('x', OY, OX, OX + RW, room.doors.up ? xGap : null); // север
this.addWall('x', OY + RH, OX, OX + RW, room.doors.down ? xGap : null); // юг
this.addWall('z', OX, OY, OY + RH, room.doors.left ? zGap : null); // запад
this.addWall('z', OX + RW, OY, OY + RH, room.doors.right ? zGap : null); // восток
// Двери (всегда видны: закрыты в бою, открыты после зачистки).
const open = room.cleared;
if (room.doors.up) this.addDoor('x', OX + 7.5 * TILE, OY, open);
if (room.doors.down) this.addDoor('x', OX + 7.5 * TILE, OY + RH, open);
if (room.doors.left) this.addDoor('z', OX, OY + 5.5 * TILE, open);
if (room.doors.right) this.addDoor('z', OX + RW, OY + 5.5 * TILE, open);
}
/** Вертикальная стена вдоль оси axis на координате edge от a до b, с проёмом gap. */
private addWall(axis: 'x' | 'z', edge: number, a: number, b: number, gap: [number, number] | null): void {
const segs: Array<[number, number]> = gap
? [[a, gap[0]], [gap[1], b]].filter(([s, e]) => e - s > 1) as Array<[number, number]>
: [[a, b]];
for (const [s, e] of segs) {
const mesh = new THREE.Mesh(this.vGeo, this.wallMat);
const mid = (s + e) / 2;
mesh.scale.set(e - s, WALL_H, 1);
if (axis === 'x') mesh.position.set(mid, WALL_H / 2, edge);
else { mesh.rotation.y = Math.PI / 2; mesh.position.set(edge, WALL_H / 2, mid); }
this.roomGroup.add(mesh);
}
}
/** Дверь в проёме: вертикальный спрайт (закрытая/открытая). */
private addDoor(axis: 'x' | 'z', x: number, edgeOrZ: number, open: boolean): void {
const mat = new THREE.MeshBasicMaterial({ map: this.assets.door(open), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(this.vGeo, mat);
mesh.scale.set(GAP, WALL_H, 1);
if (axis === 'x') mesh.position.set(x, WALL_H / 2, edgeOrZ);
else { mesh.rotation.y = Math.PI / 2; mesh.position.set(x, WALL_H / 2, edgeOrZ); }
mesh.userData.disposable = true; // материал двери персональный — освобождаем
this.roomGroup.add(mesh);
}
// ── Динамика ──────────────────────────────────────────────
private syncPlayer(game: Game, alpha: number): void {
const p = game.player;
const x = lerp(p.prevX, p.x, alpha);
const z = lerp(p.prevY, p.y, alpha);
this.playerMesh.material = p.mode === MODE_RANGED ? this.playerMat.ranged : this.playerMat.melee;
const w = p.w * SPRITE_SCALE;
const h = w * SPRITE_ASPECT;
this.playerMesh.scale.set(w, h, 1);
this.playerMesh.position.set(x, h / 2, z);
this.placeShadow(this.playerShadow, x, z, p.w);
// I-frames: мигаем спрайтом (классические кадры неуязвимости).
this.playerMesh.visible = !(p.invTimer > 0 && p.invTimer % 6 < 3);
// Вспышка из дула при выстреле (atkCD «подскочил» вверх).
if (p.mode === MODE_RANGED && p.atkCD > this.lastAtkCD) {
const [dx, dz] = DIR[p.facing];
this.spawnEffect(this.assets.muzzle(), this.theme.flash,
x + dx * (p.w * 0.7), h * 0.55, z + dz * (p.w * 0.7), 16, 6, { vy: 0, grow: 1.04 });
}
this.lastAtkCD = p.atkCD;
}
private syncEnemies(room: Room, alpha: number): void {
const live = new Set<Enemy>();
for (const e of room.enemies) {
if (!e.alive) continue;
live.add(e);
let v = this.enemyVisuals.get(e);
if (!v) {
const mat = new THREE.MeshBasicMaterial({
map: this.assets.sprite(this.enemyMatKey[e.type]), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide,
});
const sprite = new THREE.Mesh(this.vGeo, mat);
const shadow = this.flatMesh(this.shadowMat);
this.scene.add(sprite, shadow);
v = { sprite, shadow, lastHit: 0 };
this.enemyVisuals.set(e, v);
}
const x = lerp(e.prevX, e.x, alpha);
const z = lerp(e.prevY, e.y, alpha);
const pop = 1 + 0.18 * (e.hitTimer / Math.max(1, MELEE.life)); // «дёргается» при попадании
const w = e.w * SPRITE_SCALE * pop;
const h = w * SPRITE_ASPECT;
v.sprite.scale.set(w, h, 1);
v.sprite.position.set(x, h / 2, z);
this.placeShadow(v.shadow, x, z, e.w);
// Искра в момент попадания (hitTimer вырос).
if (e.hitTimer > v.lastHit) {
this.spawnEffect(this.assets.spark(), this.theme.flash, x, e.w * 0.6, z, e.w * 0.9, 8, { vy: 0.6, grow: 1.06 });
}
v.lastHit = e.hitTimer;
}
// Уборка: исчезнувшие враги. Если враг мёртв — «пуф» на месте гибели.
for (const [e, v] of this.enemyVisuals) {
if (live.has(e)) continue;
if (!e.alive) {
this.spawnEffect(this.assets.puff(), 0xffffff, e.x, e.w * 0.6, e.y, e.w * 1.2, 14, { vy: 1.1, grow: 1.07 });
}
this.scene.remove(v.sprite, v.shadow);
(v.sprite.material as THREE.Material).dispose();
this.enemyVisuals.delete(e);
}
}
private syncTears(room: Room, alpha: number): void {
const live = new Set<Projectile>();
for (const t of room.tears) {
if (!t.alive) continue;
live.add(t);
let mesh = this.tearMeshes.get(t);
if (!mesh) {
mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
map: this.assets.tear(), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
}));
const s = PROJECTILE.radius * 4;
mesh.scale.set(s, s, 1);
this.scene.add(mesh);
this.tearMeshes.set(t, mesh);
}
mesh.position.set(lerp(t.prevX, t.x, alpha), 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);
}
}
private syncSwing(game: Game): void {
const s = game.meleeSwing;
if (!s || !s.alive) { this.swingMesh.visible = false; return; }
this.swingMesh.visible = true;
this.swingMesh.position.set(s.box.x + s.box.w / 2, 2, s.box.y + s.box.h / 2);
this.swingMesh.scale.set(s.box.w * 1.4, s.box.h * 1.4, 1);
(this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / MELEE.life);
}
// ── Эффекты (частицы-биллборды) ───────────────────────────
private spawnEffect(
tex: THREE.Texture, color: number, x: number, y: number, z: number,
size: number, life: number, opts: { vy: number; grow: number },
): void {
const mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
map: tex, color: new THREE.Color(color), transparent: true,
blending: THREE.AdditiveBlending, depthWrite: false,
}));
mesh.scale.set(size, size, 1);
mesh.position.set(x, y, z);
this.scene.add(mesh);
this.effects.push({ mesh, life, max: life, vy: opts.vy, grow: opts.grow });
}
private updateEffects(): void {
for (let i = this.effects.length - 1; i >= 0; i--) {
const fx = this.effects[i];
fx.life--;
const mat = fx.mesh.material as THREE.MeshBasicMaterial;
if (fx.life <= 0) {
this.scene.remove(fx.mesh);
mat.dispose();
this.effects.splice(i, 1);
continue;
}
mat.opacity = fx.life / fx.max;
fx.mesh.position.y += fx.vy;
fx.mesh.scale.multiplyScalar(fx.grow);
}
}
// ── Хелперы ───────────────────────────────────────────────
/** Плоский (лежащий на полу) меш из общей геометрии. */
private flatMesh(mat: THREE.Material): THREE.Mesh {
const m = new THREE.Mesh(this.flatGeo, mat);
m.rotation.x = -Math.PI / 2; // положить плашмя, нормаль вверх
return m;
}
private placeShadow(shadow: THREE.Mesh, x: number, z: number, footprint: number): void {
shadow.position.set(x, SHADOW_Y, z);
shadow.scale.set(footprint * 1.4, footprint * 0.9, 1);
}
private clearGroup(group: THREE.Group): void {
for (const child of group.children) {
// Общие материалы (пол/стены) не трогаем; персональные (двери) — освобождаем.
if ((child as THREE.Mesh).userData?.disposable) {
((child as THREE.Mesh).material as THREE.Material).dispose();
}
}
group.clear();
}
dispose(): void {
this.clearGroup(this.roomGroup);
for (const v of this.enemyVisuals.values()) (v.sprite.material as THREE.Material).dispose();
for (const m of this.tearMeshes.values()) (m.material as THREE.Material).dispose();
for (const fx of this.effects) (fx.mesh.material as THREE.Material).dispose();
(this.swingMesh.material as THREE.Material).dispose();
this.floorMat.dispose();
this.wallMat.dispose();
this.shadowMat.dispose();
this.playerMat.ranged.dispose();
this.playerMat.melee.dispose();
this.vGeo.dispose();
this.flatGeo.dispose();
this.assets.dispose();
this.renderer.dispose();
}
}
+237
View File
@@ -0,0 +1,237 @@
import * as THREE from 'three';
/**
* assets.ts — поставщик текстур. Каждая текстура грузится из
* `src/assets/<ключ>.png` через THREE.TextureLoader; если PNG нет — рисуется
* процедурный фолбэк на canvas (функции drawX ниже), чтобы игра работала без
* ассетов. Текстуры кэшируются и освобождаются в dispose().
*
* Как заменить/добавить графику: положи PNG с именем `<ключ>.png` в `src/assets/`
* (dev-сервер отдаёт их из src/assets, прод-сборка копирует в dist/assets). Код
* трогать не нужно. Функции drawX — это лишь плейсхолдер-фолбэк; правь их, только
* если хочешь другой запасной рисунок. Полный список ключей — в docs/ASSET_BRIEF.md.
*/
export type SpriteKey =
| 'player-ranged' | 'player-melee'
| 'enemy-normal' | 'enemy-fast' | 'enemy-boss';
function canvas(w: number, h: number): { cv: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
const cv = document.createElement('canvas');
cv.width = w;
cv.height = h;
return { cv, ctx: cv.getContext('2d')! };
}
/** Скруглённый прямоугольник (хелпер рисования). */
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
/** Большеголовый персонаж в духе Isaac (вертикальный спрайт 48×64). */
function drawCharacter(
opts: { head: string; body: string; outline: string; eye?: string; horns?: boolean; small?: boolean },
): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 64);
const cx = 24;
const scale = opts.small ? 0.85 : 1;
const headR = 15 * scale;
const headY = 24;
// Тело (туника) снизу.
ctx.fillStyle = opts.body;
roundRect(ctx, cx - 13 * scale, headY + 6, 26 * scale, 26 * scale, 6);
ctx.fill();
ctx.strokeStyle = opts.outline;
ctx.lineWidth = 2;
ctx.stroke();
// Ножки.
ctx.fillStyle = opts.outline;
ctx.fillRect(cx - 9 * scale, headY + 28, 6, 8);
ctx.fillRect(cx + 3 * scale, headY + 28, 6, 8);
// Рога (для босса) — за головой.
if (opts.horns) {
ctx.fillStyle = opts.outline;
ctx.beginPath();
ctx.moveTo(cx - 13, headY - 9); ctx.lineTo(cx - 18, headY - 22); ctx.lineTo(cx - 6, headY - 11); ctx.fill();
ctx.beginPath();
ctx.moveTo(cx + 13, headY - 9); ctx.lineTo(cx + 18, headY - 22); ctx.lineTo(cx + 6, headY - 11); ctx.fill();
}
// Голова.
ctx.fillStyle = opts.head;
ctx.beginPath();
ctx.arc(cx, headY, headR, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = opts.outline;
ctx.lineWidth = 2;
ctx.stroke();
// Глаза.
ctx.fillStyle = opts.eye ?? '#1a1a1a';
ctx.beginPath(); ctx.arc(cx - 6, headY - 1, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx + 6, headY - 1, 3, 0, Math.PI * 2); ctx.fill();
return cv;
}
/** Плиточная текстура пола (тёмный камень, бесшовная). */
function drawFloor(): HTMLCanvasElement {
const { cv, ctx } = canvas(64, 64);
ctx.fillStyle = '#6b6657';
ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = '#5f5a4c';
ctx.fillRect(0, 0, 32, 32);
ctx.fillRect(32, 32, 32, 32);
// лёгкие «трещинки»/крапинки
ctx.fillStyle = 'rgba(0,0,0,0.13)';
for (const [x, y] of [[8, 12], [40, 6], [54, 40], [18, 48], [30, 28]]) ctx.fillRect(x, y, 3, 3);
ctx.strokeStyle = 'rgba(0,0,0,0.18)';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, 63, 63);
return cv;
}
/** Кирпичная текстура стены. */
function drawWall(): HTMLCanvasElement {
const { cv, ctx } = canvas(64, 64);
ctx.fillStyle = '#474757';
ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = '#55556a';
const bh = 16;
for (let row = 0; row * bh < 64; row++) {
const off = row % 2 === 0 ? 0 : -16;
for (let x = off; x < 64; x += 32) {
ctx.fillRect(x + 1, row * bh + 1, 30, bh - 2);
}
}
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.strokeRect(0.5, 0.5, 63, 63);
return cv;
}
/** Снаряд-«слеза» (голубая капля со свечением, прозрачный фон). */
function drawTear(): HTMLCanvasElement {
const { cv, ctx } = canvas(32, 32);
const g = ctx.createRadialGradient(16, 16, 1, 16, 16, 15);
g.addColorStop(0, '#dff0ff');
g.addColorStop(0.4, '#6699cc');
g.addColorStop(1, 'rgba(40,80,140,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(16, 16, 15, 0, Math.PI * 2); ctx.fill();
return cv;
}
/** Радиальная мягкая «вспышка» (для дула, попаданий, частиц). */
function drawGlow(inner: string, outer: string): HTMLCanvasElement {
const { cv, ctx } = canvas(64, 64);
const g = ctx.createRadialGradient(32, 32, 1, 32, 32, 31);
g.addColorStop(0, inner);
g.addColorStop(0.5, outer);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 64, 64);
return cv;
}
/** Дверь: закрытая (засов) или открытый тёмный проём. Прозрачный фон. */
function drawDoor(open: boolean): HTMLCanvasElement {
const { cv, ctx } = canvas(64, 64);
// Рама-арка.
ctx.fillStyle = '#1c1a14';
ctx.fillRect(4, 4, 56, 60);
ctx.fillStyle = '#070707'; // тёмный проём
ctx.fillRect(12, 12, 40, 52);
if (!open) {
// Створки + засов (закрыто).
ctx.fillStyle = '#3a2e14';
ctx.fillRect(12, 12, 40, 52);
ctx.strokeStyle = '#241a08';
ctx.lineWidth = 2;
for (let x = 18; x < 52; x += 10) { ctx.beginPath(); ctx.moveTo(x, 12); ctx.lineTo(x, 64); ctx.stroke(); }
ctx.fillStyle = '#9a8a4a'; // засов
ctx.fillRect(10, 32, 44, 7);
ctx.fillStyle = '#cdbf78';
ctx.fillRect(28, 30, 8, 11);
}
return cv;
}
/** Мягкая тень-«пятно» под сущностью. */
function drawShadow(): HTMLCanvasElement {
const { cv, ctx } = canvas(64, 32);
const g = ctx.createRadialGradient(32, 16, 1, 32, 16, 30);
g.addColorStop(0, 'rgba(0,0,0,0.5)');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.save(); ctx.scale(1, 0.5); ctx.beginPath(); ctx.arc(32, 32, 30, 0, Math.PI * 2); ctx.fill(); ctx.restore();
return cv;
}
/**
* Кэширующий поставщик текстур. Строит лениво, отдаёт по имени, освобождает все.
*/
export class Assets {
private cache = new Map<string, THREE.Texture>();
private readonly loader = new THREE.TextureLoader();
/**
* Возвращает текстуру по ключу. Сначала пытается загрузить PNG из
* `src/assets/<key>.png` (поставляется художником, см. docs/ASSET_BRIEF.md);
* если файла нет — рисует процедурный фолбэк, чтобы игра не ломалась.
* 404 в консоли для ещё не добавленных ассетов — это норма (сработал фолбэк).
*/
private get(key: string, build: () => HTMLCanvasElement, pixelated = true): THREE.Texture {
const cached = this.cache.get(key);
if (cached) return cached;
const tex = this.loader.load(
`assets/${key}.png`,
undefined,
undefined,
() => { tex.image = build() as unknown as HTMLImageElement; tex.needsUpdate = true; }, // PNG нет → процедурный фолбэк
);
tex.colorSpace = THREE.SRGBColorSpace;
if (pixelated) {
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
}
this.cache.set(key, tex);
return tex;
}
sprite(key: SpriteKey): THREE.Texture {
return this.get(key, () => {
switch (key) {
case 'player-ranged': return drawCharacter({ head: '#e8d2b0', body: '#2a6a9a', outline: '#16324a', eye: '#123' });
case 'player-melee': return drawCharacter({ head: '#e8d2b0', body: '#9a3a2a', outline: '#4a160e', eye: '#123' });
case 'enemy-normal': return drawCharacter({ head: '#c08a5a', body: '#9a5a36', outline: '#3a2210', eye: '#2a1c0c' });
case 'enemy-fast': return drawCharacter({ head: '#bb3030', body: '#992222', outline: '#4a0e0e', eye: '#ffdddd', small: true });
case 'enemy-boss': return drawCharacter({ head: '#7a1414', body: '#5a0a0a', outline: '#250303', eye: '#ff4444', horns: true });
}
});
}
floor(): THREE.Texture { return this.get('floor', drawFloor); }
wall(): THREE.Texture { return this.get('wall', drawWall); }
tear(): THREE.Texture { return this.get('tear', drawTear, false); }
shadow(): THREE.Texture { return this.get('shadow', drawShadow, false); }
door(open: boolean): THREE.Texture { return this.get(open ? 'door-open' : 'door-closed', () => drawDoor(open)); }
muzzle(): THREE.Texture { return this.get('muzzle', () => drawGlow('#fffbe0', 'rgba(255,200,60,0.7)'), false); }
spark(): THREE.Texture { return this.get('spark', () => drawGlow('#ffffff', 'rgba(255,230,170,0.6)'), false); }
puff(): THREE.Texture { return this.get('puff', () => drawGlow('rgba(220,220,230,0.9)', 'rgba(120,120,140,0.4)'), false); }
dispose(): void {
for (const t of this.cache.values()) t.dispose();
this.cache.clear();
}
}
-270
View File
@@ -1,270 +0,0 @@
import { DIR, OX, OY, TILE } from '../constants';
import type { Room } from '../room/Room';
import type { Player } from '../entities/Player';
import type { MeleeSwing } from '../entities/MeleeSwing';
/** Draw enemies, player, tears, and the melee swing arc */
export function drawEntities(
ctx: CanvasRenderingContext2D,
room: Room,
player: Player,
meleeSwing: MeleeSwing | null,
): void {
// --- ENEMIES ---
for (const e of room.enemies) {
if (!e.alive) continue;
const flash = e.hitTimer > 0 && e.hitTimer % 4 < 2;
ctx.save();
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(e.x + 2, e.y + e.h / 4, e.w / 3, 4, 0, 0, Math.PI * 2);
ctx.fill();
if (e.type === 'boss') {
drawBoss(ctx, e, flash);
} else if (e.type === 'fast') {
drawFastEnemy(ctx, e, flash);
} else {
drawNormalEnemy(ctx, e, flash);
}
ctx.restore();
}
// --- PLAYER ---
drawPlayer(ctx, player);
// --- TEARS ---
for (const t of room.tears) {
if (!t.alive) continue;
ctx.save();
ctx.fillStyle = '#6699cc';
ctx.beginPath();
ctx.arc(t.x, t.y, t.r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#99bbee';
ctx.beginPath();
ctx.arc(t.x - 1.5, t.y - 1.5, t.r - 2, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// --- MELEE SWING ---
if (meleeSwing && meleeSwing.alive) {
drawMeleeSwing(ctx, meleeSwing);
}
}
function drawBoss(ctx: CanvasRenderingContext2D, e: any, flash: boolean): void {
ctx.fillStyle = flash ? '#ddd' : '#5a0a0a';
ctx.beginPath();
ctx.arc(e.x, e.y, e.w / 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#4a0808';
ctx.beginPath();
ctx.arc(e.x - 3, e.y - 3, e.w / 2 - 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = flash ? '#000' : '#ff3333';
ctx.beginPath();
ctx.arc(e.x - 8, e.y - 8, 5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(e.x + 8, e.y - 8, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(e.x - 8, e.y - 8, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(e.x + 8, e.y - 8, 2.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = flash ? '#bbb' : '#3a0505';
ctx.beginPath();
ctx.moveTo(e.x - 16, e.y - e.w / 2 + 4);
ctx.lineTo(e.x - 8, e.y - e.w / 2 - 16);
ctx.lineTo(e.x, e.y - e.w / 2 + 4);
ctx.fill();
ctx.beginPath();
ctx.moveTo(e.x - 4, e.y - e.w / 2 + 4);
ctx.lineTo(e.x + 4, e.y - e.w / 2 - 16);
ctx.lineTo(e.x + 12, e.y - e.w / 2 + 4);
ctx.fill();
if (e.hp < e.maxHp) {
ctx.fillStyle = '#222';
ctx.fillRect(e.x - 22, e.y - e.h / 2 - 14, 44, 4);
ctx.fillStyle = '#c33';
ctx.fillRect(e.x - 22, e.y - e.h / 2 - 14, 44 * (e.hp / e.maxHp), 4);
}
}
function drawFastEnemy(ctx: CanvasRenderingContext2D, e: any, flash: boolean): void {
ctx.fillStyle = flash ? '#ddd' : '#992222';
ctx.beginPath();
ctx.arc(e.x, e.y, e.w / 2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#771111';
ctx.beginPath();
ctx.arc(e.x - 1, e.y - 1, e.w / 2 - 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ff4444';
ctx.beginPath();
ctx.arc(e.x - 5, e.y - 4, 3, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(e.x + 5, e.y - 4, 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(e.x - 5, e.y - 5, 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(e.x + 5, e.y - 5, 1.5, 0, Math.PI * 2);
ctx.fill();
}
function drawNormalEnemy(ctx: CanvasRenderingContext2D, e: any, flash: boolean): void {
ctx.fillStyle = flash ? '#ccc' : '#5a4a2e';
ctx.fillRect(e.x - e.w / 2, e.y - e.h / 2, e.w, e.h);
ctx.fillStyle = '#4a3a1e';
ctx.fillRect(e.x - e.w / 2 + 3, e.y - e.h / 2 + 3, e.w - 6, e.h - 6);
ctx.fillStyle = '#332816';
ctx.fillRect(e.x - e.w / 2 + 6, e.y - e.h / 2 + 6, e.w - 12, e.h - 12);
ctx.fillStyle = '#ffcc66';
ctx.fillRect(e.x - 7, e.y - 5, 5, 5);
ctx.fillRect(e.x + 2, e.y - 5, 5, 5);
ctx.fillStyle = '#000';
ctx.fillRect(e.x - 6, e.y - 4, 3, 3);
ctx.fillRect(e.x + 3, e.y - 4, 3, 3);
}
function drawPlayer(ctx: CanvasRenderingContext2D, p: Player): void {
ctx.save();
const flash = p.invTimer > 0 && p.invTimer % 6 < 3;
const bodyColor = p.mode === 0 ? '#2a6a9a' : '#9a3a2a';
ctx.fillStyle = flash ? '#ddd' : bodyColor;
ctx.fillRect(p.x - p.w / 2, p.y - p.h / 2, p.w, p.h);
ctx.fillStyle = flash ? '#ccc' : 'rgba(0,0,0,0.3)';
ctx.fillRect(p.x - p.w / 2 + 3, p.y - p.h / 2 + 3, p.w - 6, p.h - 6);
// Weapon
const [fx, fy] = DIR[p.facing];
const wx = p.x + fx * (p.w / 2 + 4);
const wy = p.y + fy * (p.h / 2 + 4);
if (p.mode === 0) {
drawPistol(ctx, p, wx, wy, fx, fy, flash);
} else {
drawKnife(ctx, wx, wy, fx, fy, flash);
}
// Eyes
ctx.fillStyle = '#fff';
const ex = p.x + fx * 5;
const ey = p.y + fy * 5;
ctx.fillRect(ex - 5, ey - 4, 4, 5);
ctx.fillRect(ex + 1, ey - 4, 4, 5);
ctx.fillStyle = '#111';
ctx.fillRect(ex - 4 + fx, ey - 3 + fy, 2, 3);
ctx.fillRect(ex + 2 + fx, ey - 3 + fy, 2, 3);
ctx.restore();
}
function drawPistol(
ctx: CanvasRenderingContext2D,
p: Player,
wx: number, wy: number,
fx: number, fy: number,
flash: boolean,
): void {
ctx.strokeStyle = flash ? '#999' : '#555';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
// Barrel
ctx.beginPath();
ctx.moveTo(wx, wy);
ctx.lineTo(wx + fx * 14 + fy * 2, wy + fy * 14 + fx * 2);
ctx.stroke();
// Body
ctx.fillStyle = flash ? '#aaa' : '#444';
ctx.save();
const angle = fy !== 0 ? (Math.PI / 2) * (fy < 0 ? -1 : 1) : fx < 0 ? Math.PI : 0;
ctx.translate(p.x + fx * 8, p.y + fy * 8);
ctx.rotate(angle);
ctx.fillRect(-7, -4, 14, 8);
ctx.restore();
// Muzzle flash
if (p.atkCD > 8 && p.mode === 0) {
ctx.fillStyle = 'rgba(255,200,50,0.6)';
ctx.beginPath();
ctx.arc(wx + fx * 16, wy + fy * 16, 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,200,0.4)';
ctx.beginPath();
ctx.arc(wx + fx * 18, wy + fy * 18, 8, 0, Math.PI * 2);
ctx.fill();
}
}
function drawKnife(
ctx: CanvasRenderingContext2D,
wx: number, wy: number,
fx: number, fy: number,
flash: boolean,
): void {
ctx.strokeStyle = flash ? '#bbb' : '#ccc';
ctx.lineWidth = 2;
// Blade triangle
const kx = wx + fx * 6;
const ky = wy + fy * 6;
ctx.beginPath();
ctx.moveTo(kx, ky);
ctx.lineTo(kx + fx * 16 - fy * 6, ky + fy * 16 + fx * 6);
ctx.lineTo(kx + fx * 16 + fy * 6, ky + fy * 16 - fx * 6);
ctx.closePath();
ctx.fillStyle = flash ? '#ddd' : '#d4d4d4';
ctx.fill();
ctx.stroke();
// Handle
ctx.fillStyle = flash ? '#a99' : '#5a3a1a';
ctx.fillRect(kx - fx * 3 - fy * 3, ky - fy * 3 - fx * 3, 8, 8);
// Guard
ctx.fillStyle = flash ? '#bbb' : '#888';
ctx.fillRect(kx - fx * 2 - fy * 5, ky - fy * 2 - fx * 5, 5, 12);
}
function drawMeleeSwing(ctx: CanvasRenderingContext2D, s: MeleeSwing): void {
const alpha = s.life / 10;
ctx.save();
ctx.globalAlpha = alpha * 0.35;
ctx.fillStyle = '#cc8844';
ctx.fillRect(s.box.x, s.box.y, s.box.w, s.box.h);
ctx.globalAlpha = alpha;
ctx.strokeStyle = '#ddbb88';
ctx.lineWidth = 2;
ctx.strokeRect(s.box.x, s.box.y, s.box.w, s.box.h);
ctx.globalAlpha = alpha * 0.8;
ctx.strokeStyle = '#ffcc88';
ctx.lineWidth = 3;
const [dx, dy] = DIR[s.dir];
ctx.beginPath();
ctx.moveTo(s.box.x + s.box.w / 2 - dx * 18, s.box.y + s.box.h / 2 - dy * 18);
ctx.lineTo(s.box.x + s.box.w / 2 + dx * 18, s.box.y + s.box.h / 2 + dy * 18);
ctx.stroke();
ctx.restore();
}
-100
View File
@@ -1,100 +0,0 @@
import { CW, CH, OY, RH } from '../constants';
import { MODE_RANGED, MODE_MELEE } from '../constants';
import type { Player } from '../entities/Player';
import type { Room } from '../room/Room';
/** Draw the HUD: HP bar, mode indicator, enemy count, room label */
export function drawHUD(ctx: CanvasRenderingContext2D, player: Player, room: Room): void {
drawHPBar(ctx, player);
drawModeIndicator(ctx, player);
drawEnemyCount(ctx, room);
drawRoomLabel(ctx, room);
}
function drawHPBar(ctx: CanvasRenderingContext2D, p: Player): void {
const bx = 20, by = 20, bw = 140, bh = 14;
ctx.fillStyle = '#111';
ctx.fillRect(bx, by, bw, bh);
ctx.fillStyle = '#2a0a0a';
ctx.fillRect(bx + 2, by + 2, bw - 4, bh - 4);
const ratio = Math.max(0, p.hp / p.maxHp);
const color = ratio > 0.5 ? '#993333' : ratio > 0.25 ? '#994422' : '#663322';
ctx.fillStyle = color;
ctx.fillRect(bx + 2, by + 2, (bw - 4) * ratio, 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);
}
function drawModeIndicator(ctx: CanvasRenderingContext2D, p: Player): void {
const my = CH - 46;
ctx.textAlign = 'center';
const label = p.mode === MODE_RANGED ? 'RANGED' : 'MELEE';
const color = p.mode === MODE_RANGED ? '#4488cc' : '#cc6644';
ctx.fillStyle = '#0d0d0d';
ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
ctx.fillStyle = color;
ctx.font = 'bold 17px monospace';
ctx.fillText(`[ ${label} ]`, CW / 2, my + 8);
ctx.fillStyle = '#555';
ctx.font = '11px monospace';
ctx.fillText('[Tab/Q] switch', CW / 2, my - 26);
// Small weapon icon
if (p.mode === MODE_RANGED) {
ctx.strokeStyle = '#88bbdd';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(CW / 2 - 82, my - 4);
ctx.lineTo(CW / 2 - 72, my - 4);
ctx.stroke();
ctx.fillStyle = '#88bbdd';
ctx.fillRect(CW / 2 - 82, my - 8, 10, 8);
} else {
ctx.fillStyle = '#ddbb88';
ctx.beginPath();
ctx.moveTo(CW / 2 - 82, my - 10);
ctx.lineTo(CW / 2 - 74, my - 2);
ctx.lineTo(CW / 2 - 82, my + 4);
ctx.fill();
}
}
function drawEnemyCount(ctx: CanvasRenderingContext2D, room: Room): void {
const alive = room.enemies.filter(e => e.alive).length;
ctx.textAlign = 'left';
if (alive > 0) {
ctx.fillStyle = '#aa4444';
ctx.font = '13px monospace';
ctx.fillText(`\u25B6 ${alive}`, 20, CH - 18);
} else if (!room.cleared && room.type !== 'spawn') {
ctx.fillStyle = '#886633';
ctx.font = '13px monospace';
ctx.fillText('Clear the room', 20, CH - 18);
}
}
function drawRoomLabel(ctx: CanvasRenderingContext2D, room: Room): void {
if (!room.visited) return;
ctx.textAlign = 'right';
const labels: Record<string, string> = { spawn: 'START', normal: '', treasure: 'TREASURE', boss: 'BOSS' };
const label = labels[room.type];
if (label) {
ctx.fillStyle = '#555';
ctx.font = '11px monospace';
ctx.fillText(label, CW - 20, OY + RH + 30);
}
}
-59
View File
@@ -1,59 +0,0 @@
import { CW } from '../constants';
import type { RoomMap } from '../room/RoomMap';
const CELL = 14;
const GAP = 2;
/** Draw the 7×7 minimap in the top-right corner */
export function drawMinimap(
ctx: CanvasRenderingContext2D,
map: RoomMap,
cc: number,
cr: number,
): void {
const cs = CELL + GAP;
const mx = CW - 180;
const my = 12;
ctx.fillStyle = 'rgba(0,0,0,0.75)';
ctx.fillRect(mx - 8, my - 8, cs * 7 + 16, cs * 7 + 16);
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.strokeRect(mx - 8, my - 8, cs * 7 + 16, cs * 7 + 16);
for (let r = -3; r <= 3; r++) {
for (let c = -3; c <= 3; c++) {
const room = map.get(cc + c, cr + r);
if (!room) continue;
const x = mx + (c + 3) * cs;
const y = my + (r + 3) * cs;
let color = '#141414';
if (room.visited) {
color = room.type === 'spawn' ? '#2a5a2a'
: room.type === 'boss' ? '#5a1a1a'
: room.type === 'treasure' ? '#5a5a1a'
: '#555';
}
ctx.fillStyle = color;
ctx.fillRect(x, y, CELL, CELL);
if (room.visited) {
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 1;
if (room.doors.up) ctx.fillRect(x + cs / 2 - 2, y - 2, 4, 3);
if (room.doors.down) ctx.fillRect(x + cs / 2 - 2, y + CELL - 1, 4, 3);
if (room.doors.left) ctx.fillRect(x - 2, y + cs / 2 - 2, 3, 4);
if (room.doors.right) ctx.fillRect(x + CELL - 1, y + cs / 2 - 2, 3, 4);
}
// Highlight current room
if (c === 0 && r === 0) {
ctx.strokeStyle = '#ddd';
ctx.lineWidth = 2;
ctx.strokeRect(x - 1.5, y - 1.5, CELL + 3, CELL + 3);
}
}
}
}
-50
View File
@@ -1,50 +0,0 @@
import { OX, OY, TILE, COLS, ROWS, RW, RH } from '../constants';
import { T_WALL, T_DOOR } from '../room/tiles';
import type { Room } from '../room/Room';
/** Draw the tile grid and wall overlays for a room */
export function drawRoom(ctx: CanvasRenderingContext2D, room: Room): void {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = OX + c * TILE;
const y = OY + r * TILE;
const t = room.tiles[r][c];
if (t === T_WALL) {
ctx.fillStyle = '#1a1a24';
ctx.fillRect(x, y, TILE, TILE);
ctx.fillStyle = '#242436';
ctx.fillRect(x + 2, y + 2, TILE - 4, TILE - 4);
ctx.fillStyle = '#1e1e2c';
ctx.fillRect(x + 4, y + 4, TILE - 8, TILE - 8);
ctx.strokeStyle = '#161620';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y + TILE / 2);
ctx.lineTo(x + TILE, y + TILE / 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + TILE / 2, y);
ctx.lineTo(x + TILE / 2, y + TILE / 2);
ctx.stroke();
} else if (t === T_DOOR) {
ctx.fillStyle = '#0d0d14';
ctx.fillRect(x, y, TILE, TILE);
ctx.fillStyle = '#2a1e0e';
ctx.fillRect(x + 6, y + 6, TILE - 12, TILE - 12);
ctx.fillStyle = '#3a2e14';
ctx.fillRect(x + 10, y + 10, TILE - 20, TILE - 20);
} else {
const dark = (r + c) % 2 === 0;
ctx.fillStyle = dark ? '#2e2e24' : '#353528';
ctx.fillRect(x, y, TILE, TILE);
}
}
}
// Border stroke
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 2;
ctx.strokeRect(OX, OY, RW, RH);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* theme.ts — цвета-ТИНТЫ, которыми пользуется рендер напрямую.
*
* Основной внешний вид мира (пол, стены, двери, персонажи, снаряды) теперь живёт
* в текстурах `render/assets.ts` (процедурные спрайты). Сюда вынесено лишь то, что
* рендер задаёт цветом материала, а не текстурой:
* • bg — цвет фона (clear color) сцены;
* • swing — тинт спрайта взмаха ближнего боя;
* • flash — тинт вспышек/искр (дуло, попадание).
*
* Хочешь полностью сменить стиль — меняй ассеты (см. `docs/HOWTO.md` и
* `docs/ASSET_BRIEF.md`); хочешь подкрутить фон/эффекты — здесь.
*/
export interface Theme {
bg: number; // фон сцены (0xRRGGBB)
swing: number; // тинт взмаха ближнего боя
flash: number; // тинт вспышек/искр
}
/** Тема по умолчанию — «тёмное подземелье». */
export const DEFAULT_THEME: Theme = {
bg: 0x1c1c28,
swing: 0xcc8844,
flash: 0xdddddd,
};
-28
View File
@@ -1,28 +0,0 @@
import type { RoomType, Doors } from '../types';
import { buildTiles } from './tiles';
import { Enemy } from '../entities/Enemy';
import { Tear } from '../entities/Tear';
export class Room {
c: number;
r: number;
type: RoomType;
doors: Doors = { up: false, down: false, left: false, right: false };
visited = false;
cleared = false;
enemies: Enemy[] = [];
tears: Tear[] = [];
tiles: number[][];
constructor(c: number, r: number, type: RoomType) {
this.c = c;
this.r = r;
this.type = type;
this.tiles = buildTiles();
}
/** Rebuild base tiles and optionally place doors if cleared */
buildTiles(): void {
this.tiles = buildTiles(this.cleared || this.type === 'spawn' ? this.doors : undefined);
}
}
-89
View File
@@ -1,89 +0,0 @@
import { MAP_RADIUS, MIN_ROOMS, EXTRA_ROOMS } from '../constants';
import { Room } from './Room';
import type { RoomType } from '../types';
import { shuffle, ri } from '../math';
import { OPP } from '../doors';
export class RoomMap {
rooms: Map<string, Room> = new Map();
key(c: number, r: number): string {
return c + ',' + r;
}
get(c: number, r: number): Room | undefined {
return this.rooms.get(this.key(c, r));
}
has(c: number, r: number): boolean {
return this.rooms.has(this.key(c, r));
}
private add(c: number, r: number, type: RoomType): Room {
const room = new Room(c, r, type);
this.rooms.set(this.key(c, r), room);
return room;
}
hasBoss(): boolean {
for (const room of this.rooms.values()) {
if (room.type === 'boss') return true;
}
return false;
}
/** Generate a connected 7×7 grid of rooms using a random walk */
generate(): void {
this.add(0, 0, 'spawn');
const frontier: [number, number][] = [[0, 0]];
let count = 1;
const target = MIN_ROOMS + ri(0, EXTRA_ROOMS);
const dirs: [string, number, number][] = [
['up', 0, -1],
['down', 0, 1],
['left', -1, 0],
['right', 1, 0],
];
while (frontier.length > 0 && count < target) {
const idx = ri(0, frontier.length - 1);
const [cr, cc] = frontier[idx];
shuffle(dirs);
let added = false;
for (const [_d, dc, dr] of dirs) {
if (count >= target) break;
const nc = cr + dc;
const nr = cc + dr;
if (Math.abs(nc) > MAP_RADIUS || Math.abs(nr) > MAP_RADIUS) continue;
if (this.has(nc, nr)) continue;
let type: RoomType = 'normal';
if (!this.hasBoss() && (count === target - 1 || (Math.random() < 0.2 && count >= 3))) {
type = 'boss';
} else if (Math.random() < 0.12 && count >= 2) {
type = 'treasure';
}
this.add(nc, nr, type);
const dir = _d as keyof typeof OPP;
this.get(cr, cc)!.doors[dir] = true;
this.get(nc, nr)!.doors[OPP[dir] as keyof typeof OPP] = true;
frontier.push([nc, nr]);
count++;
added = true;
}
if (!added) frontier.splice(idx, 1);
}
// Ensure at least one boss room exists
if (!this.hasBoss()) {
const normals = [...this.rooms.values()].filter(r => r.type === 'normal');
if (normals.length > 0) {
normals[ri(0, normals.length - 1)].type = 'boss';
}
}
}
}
-25
View File
@@ -1,25 +0,0 @@
import { T_WALL, T_FLOOR, T_DOOR, COLS, ROWS, DOOR } from '../constants';
import type { Doors } from '../types';
export { T_WALL, T_FLOOR, T_DOOR };
/** Build a fresh tile grid (all edge tiles = wall, interior = floor) */
export function buildTiles(doorState?: Doors): number[][] {
const tiles: number[][] = [];
for (let r = 0; r < ROWS; r++) {
tiles[r] = [];
for (let c = 0; c < COLS; c++) {
tiles[r][c] = (r === 0 || r === ROWS - 1 || c === 0 || c === COLS - 1) ? T_WALL : T_FLOOR;
}
}
if (doorState) placeDoors(tiles, doorState);
return tiles;
}
/** Mark door tiles on an existing tile grid */
export function placeDoors(tiles: number[][], doors: Doors): void {
if (doors.up) for (const c of DOOR.up.cols) tiles[DOOR.up.row][c] = T_DOOR;
if (doors.down) for (const c of DOOR.down.cols) tiles[DOOR.down.row][c] = T_DOOR;
if (doors.left) for (const r of DOOR.left.rows) tiles[r][DOOR.left.col] = T_DOOR;
if (doors.right) for (const r of DOOR.right.rows) tiles[r][DOOR.right.col] = T_DOOR;
}
+45
View File
@@ -0,0 +1,45 @@
import type { LevelRules } from '../core/rules';
/**
* Стартовое меню на DOM (поверх холстов). Показывает список пресетов-уровней;
* по клику зовёт onStart с выбранными правилами. DOM-меню выбрано осознанно:
* его проще стилизовать и расширять (новые поля, превью), чем рисовать UI в WebGL.
*
* Чтобы добавить пункт меню — добавь пресет в core/rules.ts (PRESETS): он
* появится здесь автоматически.
*/
export class StartMenu {
private readonly root: HTMLElement;
constructor(root: HTMLElement, presets: LevelRules[], onStart: (rules: LevelRules) => void) {
this.root = root;
const list = root.querySelector('#menu-presets');
if (!list) throw new Error('StartMenu: не найден #menu-presets внутри #menu');
for (const rules of presets) {
const btn = document.createElement('button');
btn.className = 'menu-preset';
btn.type = 'button';
const name = document.createElement('span');
name.className = 'menu-preset-name';
name.textContent = rules.name;
const desc = document.createElement('span');
desc.className = 'menu-preset-desc';
desc.textContent = rules.description;
btn.append(name, desc);
btn.addEventListener('click', () => onStart(rules));
list.appendChild(btn);
}
}
show(): void {
this.root.style.display = 'flex';
}
hide(): void {
this.root.style.display = 'none';
}
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'bun:test';
import { isBlocked, collidesWall } from '../src/core/systems/collision';
import { Room } from '../src/core/world/Room';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
function cleanRoom(): Room {
const room = new Room(0, 0, 'normal');
room.doors = { up: true, down: false, left: false, right: false };
room.cleared = true;
room.rebuildTiles();
return room;
}
describe('collision', () => {
it('стены по краям блокируют движение', () => {
const room = cleanRoom();
expect(isBlocked(room, 0, 0)).toBe(true);
expect(isBlocked(room, COLS - 1, ROWS - 1)).toBe(true);
});
it('внутренний пол не блокирует', () => {
const room = cleanRoom();
expect(isBlocked(room, 5, 5)).toBe(false);
});
it('за пределами комнаты — блок, но дверной проём открыт', () => {
const room = cleanRoom();
// Над комнатой (row < 0) обычно стена...
expect(isBlocked(room, 0, -1)).toBe(true);
// ...но в колонках двери up проём открыт.
expect(isBlocked(room, 7, -1)).toBe(false);
});
it('collidesWall ловит хитбокс на стене и пропускает на полу', () => {
const room = cleanRoom();
const insideFloor = { x: OX + 5 * TILE, y: OY + 5 * TILE, w: 26, h: 26 };
expect(collidesWall(insideFloor, room)).toBe(false);
const onWall = { x: OX - 5, y: OY - 5, w: 26, h: 26 };
expect(collidesWall(onWall, room)).toBe(true);
});
});
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect } from 'bun:test';
import { Game } from '../src/core/Game';
import { Rng } from '../src/core/rng';
import { Enemy } from '../src/core/entities/Enemy';
import { collidesWall } from '../src/core/systems/collision';
import { emptyInput, type InputState } from '../src/input/InputState';
import { DEFAULT_RULES, PRESETS } from '../src/core/rules';
import { MODE_RANGED, MODE_MELEE, OX, OY, TILE, COLS, RW } from '../src/config';
function input(patch: Partial<InputState> = {}): InputState {
return { ...emptyInput(), ...patch };
}
describe('Game', () => {
it('РЕГРЕССИЯ: конструируется без ошибок и игрок появляется не в стене', () => {
const game = new Game(DEFAULT_RULES, new Rng(1));
expect(game.curRoom).toBeDefined();
expect(game.curRoom.type).toBe('spawn');
expect(collidesWall(game.player.box, game.curRoom)).toBe(false);
});
it('600 пустых шагов не падают, игрок не застревает в стене, HP цело на спавне', () => {
const game = new Game(DEFAULT_RULES, new Rng(2));
const hp0 = game.player.hp;
for (let i = 0; i < 600; i++) game.step(input());
expect(collidesWall(game.player.box, game.curRoom)).toBe(false);
expect(game.player.hp).toBe(hp0); // на спавне врагов нет
expect(game.gameOver).toBe(false);
});
it('смена оружия по toggleWeapon', () => {
const game = new Game(DEFAULT_RULES, new Rng(3));
expect(game.player.mode).toBe(MODE_RANGED);
game.consumeActions(input({ toggleWeapon: true }));
expect(game.player.mode).toBe(MODE_MELEE);
game.consumeActions(input({ toggleWeapon: true }));
expect(game.player.mode).toBe(MODE_RANGED);
});
it('стрельба создаёт снаряд, который потом исчезает', () => {
const game = new Game(DEFAULT_RULES, new Rng(4));
game.step(input({ aimDir: 'right' }));
expect(game.curRoom.tears.length).toBe(1);
// Снаряд летит вправо и со временем гаснет (стена/время жизни).
for (let i = 0; i < 200; i++) game.step(input());
expect(game.curRoom.tears.length).toBe(0);
});
it('РЕГРЕССИЯ: комната без врагов (сокровищница) зачищается при входе — иначе двери не открыть', () => {
// Ищем seed, где в карте есть сокровищница.
let tested = false;
for (let seed = 1; seed <= 200 && !tested; seed++) {
const game = new Game(DEFAULT_RULES, new Rng(seed));
for (const room of game.roomMap.rooms.values()) {
if (room.type !== 'treasure') continue;
game.cc = room.c;
game.cr = room.r;
game.enterRoom('up');
expect(room.enemies.length).toBe(0);
expect(room.cleared).toBe(true); // иначе игрок застрянет без дверей
tested = true;
break;
}
}
expect(tested).toBe(true); // среди 200 seed сокровищница точно нашлась
});
it('РЕГРЕССИЯ: кнокбэк не выбрасывает врага сквозь стену (нет софт-лока)', () => {
const game = new Game(DEFAULT_RULES, new Rng(7));
// Переносим игру в любую боевую (normal) комнату.
let placed = false;
for (const room of game.roomMap.rooms.values()) {
if (room.type !== 'normal') continue;
game.cc = room.c;
game.cr = room.r;
game.enterRoom('up');
placed = true;
break;
}
expect(placed).toBe(true);
const room = game.curRoom;
// Один контролируемый враг у правой стены, кнокбэк направлен В стену.
const e = new Enemy(OX + (COLS - 2) * TILE + TILE / 2, OY + 5 * TILE + TILE / 2, 'normal');
e.knx = 50; // заведомо больше толщины стены (1 тайл)
e.kny = 0;
room.enemies = [e];
for (let i = 0; i < 40; i++) game.step(emptyInput());
expect(collidesWall(e.box, room)).toBe(false); // остался внутри и достижим
expect(e.x).toBeLessThan(OX + RW); // не вылетел за правую стену
});
it('правила уровня влияют на забег: HP игрока и размер карты', () => {
const hardcore = PRESETS.find((p) => p.id === 'hardcore')!;
const big = PRESETS.find((p) => p.id === 'big')!;
const gHard = new Game(hardcore, new Rng(11));
expect(gHard.player.maxHp).toBe(hardcore.player.maxHp); // меньше базовых 6
// «Большой данжен» в среднем даёт больше комнат, чем «Стандарт».
const avg = (rules: typeof big) => {
let sum = 0;
for (let s = 1; s <= 20; s++) sum += new Game(rules, new Rng(s)).roomMap.rooms.size;
return sum / 20;
};
expect(avg(big)).toBeGreaterThan(avg(DEFAULT_RULES));
});
it('фикс-сид даёт одинаковую карту каждый раз — в т.ч. после reset()', () => {
const daily = PRESETS.find((p) => p.id === 'daily')!;
const a = new Game(daily);
const keys = [...a.roomMap.rooms.keys()].sort();
// другой экземпляр — та же карта
const b = new Game(daily);
expect([...b.roomMap.rooms.keys()].sort()).toEqual(keys);
// РЕГРЕССИЯ: после рестарта по [R] фикс-сид воспроизводит тот же данжен
a.gameOver = true;
a.reset();
expect([...a.roomMap.rooms.keys()].sort()).toEqual(keys);
});
it('reset() возвращает чистое состояние', () => {
const game = new Game(DEFAULT_RULES, new Rng(5));
game.player.hp = 1;
game.gameOver = true;
game.reset();
expect(game.gameOver).toBe(false);
expect(game.player.hp).toBe(game.player.maxHp);
expect(game.cc).toBe(0);
expect(game.cr).toBe(0);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'bun:test';
import { Rng } from '../src/core/rng';
describe('Rng', () => {
it('детерминирован: одинаковый seed → одинаковая последовательность', () => {
const a = new Rng(12345);
const b = new Rng(12345);
const seqA = Array.from({ length: 10 }, () => a.next());
const seqB = Array.from({ length: 10 }, () => b.next());
expect(seqA).toEqual(seqB);
});
it('разные seed дают разные последовательности', () => {
const a = new Rng(1);
const b = new Rng(2);
expect(a.next()).not.toBe(b.next());
});
it('next() всегда в [0, 1)', () => {
const r = new Rng(7);
for (let i = 0; i < 1000; i++) {
const v = r.next();
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it('int(a, b) держится в границах включительно', () => {
const r = new Rng(99);
for (let i = 0; i < 1000; i++) {
const v = r.int(3, 7);
expect(v).toBeGreaterThanOrEqual(3);
expect(v).toBeLessThanOrEqual(7);
expect(Number.isInteger(v)).toBe(true);
}
});
it('shuffle сохраняет все элементы', () => {
const r = new Rng(42);
const arr = [1, 2, 3, 4, 5];
const shuffled = r.shuffle([...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('пустой массив');
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'bun:test';
import { RoomMap } from '../src/core/world/RoomMap';
import { Rng } from '../src/core/rng';
import { OPP } from '../src/config';
import type { Dir } from '../src/core/types';
const NEIGHBOR: Record<Dir, [number, number]> = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
};
describe('RoomMap', () => {
// Перебираем много seed: генерация случайная, баги могут прятаться в редких раскладах.
const maps = Array.from({ length: 50 }, (_, i) => new RoomMap(new Rng(i + 1)));
it('РЕГРЕССИЯ: карта не пустая (генерация реально вызвана)', () => {
// Именно тут раньше всё падало: конструктор не звал generate() и карта была пустой.
for (const m of maps) {
expect(m.rooms.size).toBeGreaterThan(1);
}
});
it('всегда есть спавн в (0,0)', () => {
for (const m of maps) {
expect(m.get(0, 0)?.type).toBe('spawn');
}
});
it('всегда есть ровно одна (минимум одна) комната-босс', () => {
for (const m of maps) {
const bosses = [...m.rooms.values()].filter((r) => r.type === 'boss');
expect(bosses.length).toBeGreaterThanOrEqual(1);
}
});
it('двери симметричны: дверь A→B всегда имеет встречную B→A', () => {
for (const m of maps) {
for (const room of m.rooms.values()) {
for (const dir of Object.keys(NEIGHBOR) as Dir[]) {
if (!room.doors[dir]) continue;
const [dc, dr] = NEIGHBOR[dir];
const neighbor = m.get(room.c + dc, room.r + dr);
expect(neighbor).toBeDefined();
expect(neighbor!.doors[OPP[dir]]).toBe(true);
}
}
}
});
it('карта связна: все комнаты достижимы из спавна по дверям', () => {
for (const m of maps) {
const start = m.get(0, 0)!;
const seen = new Set<string>([`${start.c},${start.r}`]);
const queue = [start];
while (queue.length) {
const room = queue.shift()!;
for (const dir of Object.keys(NEIGHBOR) as Dir[]) {
if (!room.doors[dir]) continue;
const [dc, dr] = NEIGHBOR[dir];
const n = m.get(room.c + dc, room.r + dr);
if (n && !seen.has(`${n.c},${n.r}`)) {
seen.add(`${n.c},${n.r}`);
queue.push(n);
}
}
}
expect(seen.size).toBe(m.rooms.size);
}
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'bun:test';
import { spawnEnemies } from '../src/core/systems/spawner';
import { Room } from '../src/core/world/Room';
import { Rng } from '../src/core/rng';
import { OX, OY, TILE, DOOR, SPAWN } from '../src/config';
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', () => {
it('в обычной комнате врагов в ожидаемом диапазоне', () => {
const room = new Room(1, 0, 'normal');
for (let s = 0; s < 30; s++) {
const enemies = spawnEnemies(room, 'left', OX + 5 * TILE, OY + 5 * TILE, new Rng(s + 1));
expect(enemies.length).toBeGreaterThanOrEqual(SPAWN.normalMin);
expect(enemies.length).toBeLessThanOrEqual(SPAWN.normalMin + SPAWN.normalExtra);
}
});
it('в комнате-босс ровно один враг типа boss', () => {
const room = new Room(1, 0, 'boss');
const enemies = spawnEnemies(room, 'left', OX + 5 * TILE, OY + 5 * TILE, new Rng(1));
expect(enemies.length).toBe(1);
expect(enemies[0].type).toBe('boss');
});
it('в сокровищнице врагов нет', () => {
const room = new Room(1, 0, 'treasure');
const enemies = spawnEnemies(room, 'left', OX + 5 * TILE, OY + 5 * TILE, new Rng(1));
expect(enemies.length).toBe(0);
});
it('враги не спавнятся вплотную к двери входа', () => {
const room = new Room(1, 0, 'normal');
const door = DOOR.left;
const doorX = OX + door.cx * TILE + TILE / 2;
const doorY = OY + door.cy * TILE + TILE / 2;
for (let s = 0; s < 20; s++) {
const enemies = spawnEnemies(room, 'left', OX + 7 * TILE, OY + 5 * TILE, new Rng(s + 100));
for (const e of enemies) {
expect(dist(e.x, e.y, doorX, doorY)).toBeGreaterThanOrEqual(SPAWN.minDistFromDoor);
}
}
});
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);
}
}
});
});
+5 -5
View File
@@ -3,15 +3,15 @@
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": false,
"noUnusedLocals": true,
"noUnusedParameters": false,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"sourceMap": true
"noEmit": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}