Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7362a2ad8 | ||
|
|
58bf96e9d9 | ||
|
|
7dabfd4fff | ||
|
|
35cb8de33a | ||
|
|
e2cb1ff855 | ||
|
|
3bb46e2f6b | ||
|
|
f4316431e0 | ||
|
|
5a8cf0e500 |
@@ -0,0 +1,4 @@
|
|||||||
|
# Эти строки раскомментируй, если появятся ссылки на спонсорство
|
||||||
|
# github: [VovaManul]
|
||||||
|
# patreon: # username
|
||||||
|
# ko_fi: # username
|
||||||
@@ -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 забега и т.д. -->
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: Предложить идею
|
||||||
|
about: Новая фича, улучшение или изменение
|
||||||
|
title: '[FEATURE] '
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
<!-- Какая проблема решается? Чего не хватает? -->
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
<!-- Как это должно работать? Опиши желаемое поведение. -->
|
||||||
|
|
||||||
|
## Альтернативы
|
||||||
|
|
||||||
|
<!-- Какие ещё варианты рассматривал? -->
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
- [ ] Меняет архитектуру «ядро ↔ рендер»
|
||||||
|
- [ ] Добавляет новую зависимость
|
||||||
|
- [ ] Ломает обратную совместимость
|
||||||
|
|
||||||
|
## Дополнительно
|
||||||
|
|
||||||
|
<!-- Скетчи, ссылки, примеры из других игр. -->
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## Описание
|
||||||
|
|
||||||
|
<!-- Краткое описание изменений. Что и зачем. -->
|
||||||
|
|
||||||
|
## Связанный issue
|
||||||
|
|
||||||
|
<!-- Fixes #номер -->
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [ ] `bun run check` зелёный (typecheck + tests)
|
||||||
|
- [ ] Добавлены/обновлены тесты (если менялась логика)
|
||||||
|
- [ ] Проверено в браузере (если геймплей/рендер)
|
||||||
|
- [ ] Обновлена документация (если нужно)
|
||||||
|
- [ ] Нет `any` (кроме узких мест по согласованию)
|
||||||
|
- [ ] Нет `Math.random()` в `src/core/`
|
||||||
|
|
||||||
|
## Тип изменений
|
||||||
|
|
||||||
|
- [ ] Исправление бага
|
||||||
|
- [ ] Новая функциональность
|
||||||
|
- [ ] Рефакторинг
|
||||||
|
- [ ] Документация
|
||||||
|
- [ ] Другое
|
||||||
|
|
||||||
|
## Дополнительно
|
||||||
|
|
||||||
|
<!-- Любая дополнительная информация для ревьюера -->
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# AGENTS.md — как работать с этим проектом
|
||||||
|
|
||||||
|
Инструкции для ИИ-агентов **и** разработчика. Прочитай целиком перед правками.
|
||||||
|
Цель проекта — держать **рабочую, расширяемую базу** рогалика. Не ломать то, что
|
||||||
|
работает; добавлять — по правилам ниже.
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Top-down рогалик (в духе Binding of Isaac). Логика — чистый TypeScript
|
||||||
|
(`src/core/`), рендер — three.js в псевдо-3D (наклонная `PerspectiveCamera`, `src/render/`), сборка/тесты — Bun.
|
||||||
|
Обзор — [`README.md`](./README.md), детали — [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md),
|
||||||
|
рецепты доработки — [`docs/HOWTO.md`](./docs/HOWTO.md).
|
||||||
|
|
||||||
|
## 🔑 Золотые правила (нарушать = ломать архитектуру)
|
||||||
|
|
||||||
|
1. **Ядро без рендера и DOM.** В `src/core/**` НЕЛЬЗЯ импортировать `three`,
|
||||||
|
обращаться к `window`/`document`/`canvas`. Логика общается с миром только через
|
||||||
|
`InputState` (вход) и публичные поля `Game` (для чтения рендером).
|
||||||
|
2. **Рендер ничего не меняет в игре.** `src/render/**` только ЧИТАЕТ `Game` и
|
||||||
|
рисует. Любая мутация состояния из рендера — баг.
|
||||||
|
3. **Случайность только через `Rng`.** Никаких `Math.random()` в `core/`. Это
|
||||||
|
сохраняет детерминизм (тесты, отладка по seed).
|
||||||
|
4. **Время — в «шагах» (1/60 c), а не в кадрах.** Скорости — «пиксели/шаг»,
|
||||||
|
перезарядки — «шаги». Не двигай ничего в коде рендера или прямо в rAF.
|
||||||
|
5. **Числа — не в коде.** Константы движка (размеры, геометрия дверей, базовый
|
||||||
|
баланс) — в `src/config.ts`; параметры конкретного забега (размер карты, сила
|
||||||
|
врагов, HP игрока, seed) — в правилах уровня `src/core/rules.ts`. Не раскидывай
|
||||||
|
«магические числа» по логике.
|
||||||
|
6. **Освобождай ресурсы three.js.** Создаёшь геометрию/материал на сущность —
|
||||||
|
обеспечь `dispose()` при её удалении (см. `sync*`/`sweep` в `ThreeRenderer`).
|
||||||
|
7. **Комментарии и текст для игрока — по-русски**, как в существующем коде.
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev # дев-сервер + watch → http://localhost:3000
|
||||||
|
bun run build # прод-сборка в dist/
|
||||||
|
bun run typecheck # tsc --noEmit (строгий) — НЕ ловится при bun build!
|
||||||
|
bun test # юнит-тесты ядра
|
||||||
|
bun run check # typecheck + test
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `bun build` **не проверяет типы**. Поэтому `bun run typecheck` обязателен —
|
||||||
|
> именно отсутствие тайп-чека когда-то скрыло рантайм-регрессию.
|
||||||
|
|
||||||
|
## Definition of Done (для любой правки)
|
||||||
|
|
||||||
|
1. `bun run check` зелёный (типы + тесты).
|
||||||
|
2. Если менял логику — **добавил/обновил тест** в `tests/`.
|
||||||
|
3. Если менял геймплей/рендер — **проверил в браузере** (`bun run dev`, открыть
|
||||||
|
страницу, увидеть, что играется, в консоли нет ошибок). Юнит-тесты не видят
|
||||||
|
рендер — визуальную проверку не пропускать.
|
||||||
|
4. Обновил доки, если поменялось поведение или структура.
|
||||||
|
|
||||||
|
## Где что лежит (карта для навигации)
|
||||||
|
|
||||||
|
| Хочешь поменять… | Иди в… |
|
||||||
|
|---|---|
|
||||||
|
| константы движка (размер тайла/комнаты, геометрия дверей, базовый баланс) | `src/config.ts` |
|
||||||
|
| правила уровня / пресеты в меню (размер карты, сила врагов, HP, seed) | `src/core/rules.ts` |
|
||||||
|
| поведение за один шаг (движение, атака, ИИ, переходы) | `src/core/Game.ts` |
|
||||||
|
| данные сущности | `src/core/entities/*` |
|
||||||
|
| генерацию карты | `src/core/world/RoomMap.ts` |
|
||||||
|
| форму комнаты/тайлы | `src/core/world/tiles.ts`, `Room.ts` |
|
||||||
|
| коллизии | `src/core/systems/collision.ts` |
|
||||||
|
| расстановку врагов | `src/core/systems/spawner.ts` |
|
||||||
|
| как рисуется мир (псевдо-3D) | `src/render/ThreeRenderer.ts` |
|
||||||
|
| спрайты/текстуры (графика) | `src/render/assets.ts` (бриф на художку — `docs/ASSET_BRIEF.md`) |
|
||||||
|
| цвета/тинты/фон | `src/render/theme.ts` |
|
||||||
|
| HUD/миникарту | `src/render/HudOverlay.ts` |
|
||||||
|
| стартовое меню | `src/ui/StartMenu.ts` |
|
||||||
|
| раскладку клавиш | `src/input/KeyboardController.ts` |
|
||||||
|
| тайминг/цикл, поток меню↔игра | `src/engine/GameLoop.ts`, `src/main.ts` |
|
||||||
|
|
||||||
|
Пошаговые рецепты («добавить врага», «новое оружие», «тип комнаты», «сменить
|
||||||
|
рендер») — в [`docs/HOWTO.md`](./docs/HOWTO.md).
|
||||||
|
|
||||||
|
## Грабли, на которые уже наступали (не повторять)
|
||||||
|
|
||||||
|
- **Пустая карта.** `new RoomMap(rng)` ДОЛЖЕН генерировать карту в конструкторе.
|
||||||
|
Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте.
|
||||||
|
- **`OPP` направлений.** Противоположное к `up` — это `down`, к `left` — `right`.
|
||||||
|
Любая другая раскладка ломает встречные двери и связность карты.
|
||||||
|
- **Пол «выворачивает» перспективу.** Пол в псевдо-3D надо класть ПЛАШМЯ
|
||||||
|
(`flatMesh`, поворот −90° вокруг X). Без поворота он встаёт вертикально и вид
|
||||||
|
ломается. Спрайты/стены — `DoubleSide` (камера переворачивает Y, иначе грани отсекаются).
|
||||||
|
- **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`),
|
||||||
|
иначе они показываются в размер HiDPI-буфера.
|
||||||
|
- **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) —
|
||||||
|
она должна стать `cleared` сразу при входе, иначе двери не появятся.
|
||||||
|
|
||||||
|
## Стиль кода
|
||||||
|
|
||||||
|
- TypeScript strict, без `any` (кроме узких мест вроде `window as …` в `main.ts`).
|
||||||
|
- Маленькие чистые функции для логики; классы — для сущностей/состояния.
|
||||||
|
- Имена и комментарии осмысленные, по-русски. Комментарий объясняет «почему», а не «что».
|
||||||
|
- Перед коммитом — `bun run check`.
|
||||||
|
|
||||||
|
## Чего НЕ делать без явной просьбы
|
||||||
|
|
||||||
|
- Не добавлять тяжёлые зависимости (физдвижки, фреймворки). База намеренно лёгкая.
|
||||||
|
- Не переписывать архитектуру «ядро ↔ рендер».
|
||||||
|
- Не коммитить `dist/` и `node_modules/` (см. `.gitignore`).
|
||||||
|
- Не превращать игру в полноценное 3D, пока этого не попросили (рендер для этого
|
||||||
|
готов — он изолирован, — но это отдельная большая задача).
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
## Что это
|
## Что это
|
||||||
|
|
||||||
Top-down рогалик (в духе Binding of Isaac). Логика — чистый TypeScript
|
Top-down рогалик (в духе Binding of Isaac). Логика — чистый TypeScript
|
||||||
(`src/core/`), рендер — three.js с ортокамерой (`src/render/`), сборка/тесты — Bun.
|
(`src/core/`), рендер — three.js в псевдо-3D (наклонная `PerspectiveCamera`, `src/render/`), сборка/тесты — Bun.
|
||||||
Обзор — [`README.md`](./README.md), детали — [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md),
|
Обзор — [`README.md`](./README.md), детали — [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md),
|
||||||
рецепты доработки — [`docs/HOWTO.md`](./docs/HOWTO.md).
|
рецепты доработки — [`docs/HOWTO.md`](./docs/HOWTO.md).
|
||||||
|
|
||||||
@@ -64,8 +64,9 @@ bun run check # typecheck + test
|
|||||||
| форму комнаты/тайлы | `src/core/world/tiles.ts`, `Room.ts` |
|
| форму комнаты/тайлы | `src/core/world/tiles.ts`, `Room.ts` |
|
||||||
| коллизии | `src/core/systems/collision.ts` |
|
| коллизии | `src/core/systems/collision.ts` |
|
||||||
| расстановку врагов | `src/core/systems/spawner.ts` |
|
| расстановку врагов | `src/core/systems/spawner.ts` |
|
||||||
| как рисуется мир | `src/render/ThreeRenderer.ts` |
|
| как рисуется мир (псевдо-3D) | `src/render/ThreeRenderer.ts` |
|
||||||
| цвета/внешний вид/ассеты мира | `src/render/theme.ts` |
|
| спрайты/текстуры (графика) | `src/render/assets.ts` (бриф на художку — `docs/ASSET_BRIEF.md`) |
|
||||||
|
| цвета/тинты/фон | `src/render/theme.ts` |
|
||||||
| HUD/миникарту | `src/render/HudOverlay.ts` |
|
| HUD/миникарту | `src/render/HudOverlay.ts` |
|
||||||
| стартовое меню | `src/ui/StartMenu.ts` |
|
| стартовое меню | `src/ui/StartMenu.ts` |
|
||||||
| раскладку клавиш | `src/input/KeyboardController.ts` |
|
| раскладку клавиш | `src/input/KeyboardController.ts` |
|
||||||
@@ -80,8 +81,9 @@ bun run check # typecheck + test
|
|||||||
Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте.
|
Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте.
|
||||||
- **`OPP` направлений.** Противоположное к `up` — это `down`, к `left` — `right`.
|
- **`OPP` направлений.** Противоположное к `up` — это `down`, к `left` — `right`.
|
||||||
Любая другая раскладка ломает встречные двери и связность карты.
|
Любая другая раскладка ломает встречные двери и связность карты.
|
||||||
- **Чёрный экран при работающем рендере.** Ортокамера инвертирует Y → нужен
|
- **Пол «выворачивает» перспективу.** Пол в псевдо-3D надо класть ПЛАШМЯ
|
||||||
`DoubleSide` на материалах, иначе грани отсекаются.
|
(`flatMesh`, поворот −90° вокруг X). Без поворота он встаёт вертикально и вид
|
||||||
|
ломается. Спрайты/стены — `DoubleSide` (камера переворачивает Y, иначе грани отсекаются).
|
||||||
- **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`),
|
- **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`),
|
||||||
иначе они показываются в размер HiDPI-буфера.
|
иначе они показываются в размер HiDPI-буфера.
|
||||||
- **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) —
|
- **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) —
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Кодекс поведения
|
||||||
|
|
||||||
|
## Наши обязательства
|
||||||
|
|
||||||
|
Мы, как участники и лидеры этого проекта, обязуемся создать гостеприимную
|
||||||
|
и безопасную среду для всех, независимо от возраста, телосложения,
|
||||||
|
видимых или невидимых физических особенностей, этнической принадлежности,
|
||||||
|
половых признаков, уровня опыта, образования, социально-экономического
|
||||||
|
статуса, национальности, внешности, расы, религии или сексуальной
|
||||||
|
идентичности и ориентации.
|
||||||
|
|
||||||
|
Мы обязуемся действовать и взаимодействовать так, чтобы способствовать
|
||||||
|
открытому, доброжелательному, инклюзивному и здоровому сообществу.
|
||||||
|
|
||||||
|
## Приемлемое поведение
|
||||||
|
|
||||||
|
Примеры поведения, способствующего созданию положительной среды:
|
||||||
|
|
||||||
|
- Проявление доброты и эмпатии по отношению к другим участникам
|
||||||
|
- Уважение к разным точкам зрения и опыту
|
||||||
|
- Конструктивная обратная связь
|
||||||
|
- Признание своих ошибок и извлечение уроков
|
||||||
|
|
||||||
|
## Неприемлемое поведение
|
||||||
|
|
||||||
|
- Использование сексуализированного языка или образов
|
||||||
|
- Троллинг, оскорбительные/уничижительные комментарии
|
||||||
|
- Преследование в любой форме
|
||||||
|
- Публикация личной информации без явного разрешения
|
||||||
|
- Другое поведение, которое можно обоснованно считать неуместным
|
||||||
|
|
||||||
|
## Ответственность
|
||||||
|
|
||||||
|
Лидеры проекта отвечают за разъяснение стандартов поведения и будут
|
||||||
|
предпринимать соответствующие и справедливые меры в ответ на любое
|
||||||
|
неприемлемое поведение.
|
||||||
|
|
||||||
|
## Сфера действия
|
||||||
|
|
||||||
|
Этот Кодекс поведения применяется во всех сообществах проекта, а также
|
||||||
|
в публичных местах, когда человек представляет проект.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
О случаях неприемлемого поведения можно сообщать лидерам проекта.
|
||||||
|
Все жалобы будут рассмотрены и приведут к ответным мерам, которые
|
||||||
|
сочтут необходимыми и соответствующими обстоятельствам.
|
||||||
|
|
||||||
|
## Атрибуция
|
||||||
|
|
||||||
|
Этот Кодекс поведения адаптирован из [Contributor Covenant](https://www.contributor-covenant.org),
|
||||||
|
версия 2.1.
|
||||||
@@ -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 без обсуждения.
|
||||||
@@ -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.
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
# Dungeon Crawl — рогалик в духе The Binding of Isaac
|
# Биндим Фигняшку — рогалик в духе The Binding of Isaac
|
||||||
|
|
||||||
Top-down рогалик: процедурный данжен из комнат, два режима боя (дальний/ближний),
|
Top-down рогалик: процедурный данжен из комнат, два режима боя (дальний/ближний),
|
||||||
враги, босс. Логика на чистом TypeScript, рендер — на **three.js** (ортографическая
|
враги, босс. Логика на чистом TypeScript, рендер — на **three.js** в **псевдо-3D**:
|
||||||
камера, картинка плоская 2D-сверху). Сборка — **Bun**.
|
наклонная камера, пол лежит плашмя, персонажи — вертикальные спрайты-биллборды
|
||||||
|
(как в Isaac). Сборка — **Bun**.
|
||||||
|
|
||||||
Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
|
Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
|
||||||
забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
|
забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
|
||||||
Внешний вид мира вынесен в «тему» — задел под кастомные ассеты (спрайты/текстуры).
|
Двери видны всегда (закрыты в бою, открыты после зачистки). Графика — PNG-ассеты в
|
||||||
|
`src/assets/` (грузятся в `render/assets.ts`; при отсутствии файла — процедурный
|
||||||
|
фолбэк). Бриф на художку — `docs/ASSET_BRIEF.md`.
|
||||||
|
|
||||||
> Это рабочая **база для развития**, а не готовая игра. Архитектура специально
|
> Это рабочая **база для развития**, а не готовая игра. Архитектура специально
|
||||||
> сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам.
|
> сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам.
|
||||||
@@ -44,6 +47,9 @@ bun run check # и то, и другое разом
|
|||||||
| R | заново (на экране Game Over / Victory) |
|
| R | заново (на экране Game Over / Victory) |
|
||||||
| Esc | вернуться в меню выбора уровня |
|
| Esc | вернуться в меню выбора уровня |
|
||||||
|
|
||||||
|
Клавиши привязаны к **физическим** кнопкам (`event.code`), поэтому WASD/Q/R работают
|
||||||
|
в любой раскладке (в т.ч. русской), независимо от языка ввода.
|
||||||
|
|
||||||
Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на
|
Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на
|
||||||
дверь, нажимая в её сторону.
|
дверь, нажимая в её сторону.
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ bun run check # и то, и другое разом
|
|||||||
## Стек и устройство
|
## Стек и устройство
|
||||||
|
|
||||||
- **Bun** — рантайм, бандлер и тест-раннер.
|
- **Bun** — рантайм, бандлер и тест-раннер.
|
||||||
- **three.js** — WebGL-рендер мира через ортокамеру.
|
- **three.js** — WebGL-рендер мира в псевдо-3D (наклонная `PerspectiveCamera`, биллборд-спрайты).
|
||||||
- **TypeScript (strict)** — весь код.
|
- **TypeScript (strict)** — весь код.
|
||||||
- Архитектура: **логика игры полностью отделена от рендера**. Ядро (`src/core/`)
|
- Архитектура: **логика игры полностью отделена от рендера**. Ядро (`src/core/`)
|
||||||
не знает ни про DOM, ни про three.js, поэтому его легко тестировать и при
|
не знает ни про DOM, ни про three.js, поэтому его легко тестировать и при
|
||||||
@@ -94,8 +100,9 @@ src/
|
|||||||
│ └── KeyboardController.ts клавиатура → InputState
|
│ └── KeyboardController.ts клавиатура → InputState
|
||||||
├── render/ ── РЕНДЕР (читает состояние, рисует) ──
|
├── render/ ── РЕНДЕР (читает состояние, рисует) ──
|
||||||
│ ├── Renderer.ts интерфейс рендера
|
│ ├── Renderer.ts интерфейс рендера
|
||||||
│ ├── ThreeRenderer.ts мир на three.js (ортокамера)
|
│ ├── ThreeRenderer.ts мир на three.js (псевдо-3D: наклон + биллборды)
|
||||||
│ ├── theme.ts ⭐ ВНЕШНИЙ ВИД (цвета; задел под спрайты/текстуры)
|
│ ├── assets.ts ⭐ АССЕТЫ (процедурные спрайты/текстуры → текстуры three.js)
|
||||||
|
│ ├── theme.ts цвета/тинты мира (фон, эффекты)
|
||||||
│ └── HudOverlay.ts HUD и миникарта на 2D-канвасе поверх
|
│ └── HudOverlay.ts HUD и миникарта на 2D-канвасе поверх
|
||||||
├── ui/
|
├── ui/
|
||||||
│ └── StartMenu.ts стартовое меню (DOM) с выбором уровня
|
│ └── StartMenu.ts стартовое меню (DOM) с выбором уровня
|
||||||
|
|||||||
@@ -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` из свежего клона).
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
* плюс копия index.html. Открывай dist/index.html.
|
* плюс копия index.html. Открывай dist/index.html.
|
||||||
*/
|
*/
|
||||||
import { build } from 'bun';
|
import { build } from 'bun';
|
||||||
|
import { cp, mkdir } from 'node:fs/promises';
|
||||||
|
|
||||||
const result = await build({
|
const result = await build({
|
||||||
entrypoints: ['./src/main.ts'],
|
entrypoints: ['./src/main.ts'],
|
||||||
@@ -19,4 +20,13 @@ if (!result.success) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Bun.write('./dist/index.html', await Bun.file('./index.html').text());
|
await Bun.write('./dist/index.html', await Bun.file('./index.html').text());
|
||||||
|
|
||||||
|
// Копируем картинки в dist/assets (если папка есть).
|
||||||
|
try {
|
||||||
|
await mkdir('./dist/assets', { recursive: true });
|
||||||
|
await cp('./src/assets', './dist/assets', { recursive: true });
|
||||||
|
} catch {
|
||||||
|
// src/assets ещё нет — не страшно, рендер откатится на процедурную графику.
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Сборка готова → dist/ (открой dist/index.html)');
|
console.log('Сборка готова → dist/ (открой dist/index.html)');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dungeon-crawl",
|
"name": "dungeon-crawl",
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ const server = Bun.serve({
|
|||||||
if (url.pathname === '/' || url.pathname === '/index.html') {
|
if (url.pathname === '/' || url.pathname === '/index.html') {
|
||||||
return new Response(Bun.file('./index.html'));
|
return new Response(Bun.file('./index.html'));
|
||||||
}
|
}
|
||||||
|
// Картинки отдаём прямо из src/assets/ — положил PNG → сразу подхватился (без пересборки).
|
||||||
|
if (url.pathname.startsWith('/assets/')) {
|
||||||
|
const asset = Bun.file('./src' + url.pathname);
|
||||||
|
if (await asset.exists()) return new Response(asset);
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
const file = Bun.file('./dist' + url.pathname);
|
const file = Bun.file('./dist' + url.pathname);
|
||||||
if (await file.exists()) return new Response(file);
|
if (await file.exists()) return new Response(file);
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
|
Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
|
||||||
зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
|
зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
|
||||||
|
|
||||||
1. Раз в кадр опрашиваем ввод (`controller.poll()`).
|
1. Раз в кадр опрашиваем источник ввода (`controller.poll()`). Цикл знает только
|
||||||
|
маленький интерфейс `poll(): InputState`, а не конкретную клавиатуру.
|
||||||
2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`.
|
2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`.
|
||||||
3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными
|
3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными
|
||||||
порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
|
порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
|
||||||
@@ -105,26 +106,52 @@
|
|||||||
- **`#hud`** — 2D-канвас поверх, `HudOverlay` рисует HP, индикатор режима,
|
- **`#hud`** — 2D-канвас поверх, `HudOverlay` рисует HP, индикатор режима,
|
||||||
счётчик врагов, подпись комнаты, миникарту и оверлеи Game Over/Victory.
|
счётчик врагов, подпись комнаты, миникарту и оверлеи Game Over/Victory.
|
||||||
|
|
||||||
### ThreeRenderer — ортокамера и «плоское 2D»
|
### ThreeRenderer — псевдо-3D (наклонный вид, как в Isaac)
|
||||||
|
|
||||||
- `OrthographicCamera(0, CW, 0, CH, …)` отображает мировые координаты **один в
|
Логика остаётся 2D-сверху, но РЕНДЕР — псевдо-3D:
|
||||||
один** в пиксельные (x вправо, y вниз). Поэтому вся математика ядра валидна без
|
|
||||||
пересчётов. Слои по `z` (пол < стены < сущности < снаряды).
|
|
||||||
- Камера переворачивает ось Y → инвертируется порядок вершин → при обычном
|
|
||||||
отсечении задних граней плоскости были бы невидимы. Поэтому все материалы —
|
|
||||||
**`DoubleSide`** (правильный выбор для плоских спрайтов).
|
|
||||||
|
|
||||||
> **Исторический баг №3.** Именно из-за инверсии Y и отсечения граней мир рисовался
|
- **Карта координат:** игровая точка `(x, y)` кладётся на пол в 3D как `(x, 0, y)`
|
||||||
> «в пустоту» (чёрный экран при работающих draw-call). Лечится `DoubleSide`.
|
— пол это плоскость `Y=0`, вверх это `+Y`, «низ» игры (`y`) идёт в глубину (`Z`).
|
||||||
|
Поэтому вся математика и **коллизии ядра без изменений** — псевдо-3D чисто визуальный.
|
||||||
|
- **Камера** — `PerspectiveCamera`, наклонная и фиксированная на комнату (приподнята и
|
||||||
|
отодвинута на «юг», смотрит вниз-вперёд ≈50°). Кадрирует комнату целиком.
|
||||||
|
- **Пол** — одна горизонтальная плоскость с бесшовной текстурой (повтор по сетке).
|
||||||
|
⚠️ Плоскость кладём ПЛАШМЯ через `flatMesh()` (поворот −90° вокруг X). Если забыть
|
||||||
|
поворот — пол встанет вертикально и перспектива «вывернется» (был такой баг).
|
||||||
|
- **Стены** — вертикальные плоскости по периметру с проёмами под двери.
|
||||||
|
- **Персонажи/враги/двери/снаряды** — ВЕРТИКАЛЬНЫЕ спрайты-биллборды (плоскости в XY,
|
||||||
|
нормаль +Z), стоящие на полу: центр на `y = высота/2`, низ на полу. Текстуры — из
|
||||||
|
`assets.ts` (см. ниже). Материалы — `DoubleSide` (наша камера переворачивает Y, иначе
|
||||||
|
грани отсеклись бы) + `alphaTest` для чёткого контура спрайта.
|
||||||
|
- **Двери всегда видимы:** рисуются из `room.doors` независимо от зачистки — закрытые
|
||||||
|
(засов) пока `!cleared`, открытый проём после. Группа комнаты пересобирается при смене
|
||||||
|
комнаты ИЛИ смене флага `cleared`.
|
||||||
|
- **Тени** — отдельный плоский спрайт-«пятно» под каждой сущностью.
|
||||||
|
|
||||||
|
### Ассеты (render/assets.ts)
|
||||||
|
|
||||||
|
`Assets` грузит текстуры из `src/assets/<ключ>.png` (спрайты персонажей, пол, стены,
|
||||||
|
двери, снаряд, эффекты, тень) и кэширует их; если PNG нет — рисует процедурный фолбэк
|
||||||
|
на `<canvas>`, поэтому игра не ломается без ассетов. Dev-сервер отдаёт `/assets/*` из
|
||||||
|
`src/assets/`, прод-сборка копирует их в `dist/assets/`. Подмена/добавление графики —
|
||||||
|
`docs/HOWTO.md` и `docs/ASSET_BRIEF.md`. Ключи ассетов едины в рендере, брифе и именах файлов.
|
||||||
|
|
||||||
|
### Эффекты оружия
|
||||||
|
|
||||||
|
Рендер сам распознаёт события по состоянию (ядро не трогаем):
|
||||||
|
- вспышка из дула — когда `player.atkCD` «подскочил» (выстрел);
|
||||||
|
- искра — когда у врага вырос `hitTimer` (попадание);
|
||||||
|
- облачко-«пуф» — когда мёртвый враг исчезает из комнаты.
|
||||||
|
Эффекты — короткоживущие аддитивные спрайты-биллборды, гаснут по таймеру.
|
||||||
|
|
||||||
### Управление ресурсами GPU (важно — иначе утечки)
|
### Управление ресурсами GPU (важно — иначе утечки)
|
||||||
|
|
||||||
- Общие геометрии-«единицы» (`unitPlane`, `unitCircle`) масштабируются под размер
|
- Общие геометрии переиспользуются масштабированием — не плодим геометрии.
|
||||||
сущности — не плодим геометрии.
|
- Группа комнаты (пол/стены/двери) пересобирается только при смене комнаты/`cleared`;
|
||||||
- Тайлы комнаты пересобираются **только при смене комнаты**.
|
персональные материалы дверей `dispose()`-ятся при пересборке.
|
||||||
- Меши сущностей создаются/удаляются по факту появления/исчезновения
|
- Спрайты сущностей и эффекты создаются/удаляются по факту появления/исчезновения
|
||||||
(mark-and-sweep в `sync*`), их персональные материалы корректно `dispose()`-ятся.
|
(mark-and-sweep), их персональные материалы корректно `dispose()`-ятся.
|
||||||
- Общие ресурсы освобождаются один раз в `dispose()`.
|
- Общие материалы/геометрии и `Assets` освобождаются один раз в `dispose()`.
|
||||||
|
|
||||||
## HiDPI
|
## HiDPI
|
||||||
|
|
||||||
|
|||||||
@@ -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/` — подхватится само.
|
||||||
@@ -28,8 +28,10 @@
|
|||||||
```
|
```
|
||||||
3. **`src/core/systems/spawner.ts`** — реши, когда он спавнится (логика выбора типа
|
3. **`src/core/systems/spawner.ts`** — реши, когда он спавнится (логика выбора типа
|
||||||
в начале цикла). Напр. с шансом: `rng.chance(0.15) ? 'tank' : rng.chance(ENEMY.fastChance) ? 'fast' : 'normal'`.
|
в начале цикла). Напр. с шансом: `rng.chance(0.15) ? 'tank' : rng.chance(ENEMY.fastChance) ? 'fast' : 'normal'`.
|
||||||
4. **`src/render/ThreeRenderer.ts`** — цвет в `COLOR` и выбор цвета/геометрии в
|
4. **`src/render/assets.ts`** — добавь ключ в `SpriteKey` и ветку в `Assets.sprite()`
|
||||||
`syncEnemies` (квадрат `unitPlane` или круг `unitCircle`).
|
(рисуется через `drawCharacter(...)` с твоими цветами).
|
||||||
|
5. **`src/render/ThreeRenderer.ts`** — добавь строку в `enemyMatKey`, сопоставив новый
|
||||||
|
`Enemy['type']` этому `SpriteKey` (без этого не пройдёт проверка типов).
|
||||||
|
|
||||||
ИИ, урон, отбрасывание, мигание при попадании — общие, их трогать не нужно.
|
ИИ, урон, отбрасывание, мигание при попадании — общие, их трогать не нужно.
|
||||||
Добавь тест в `tests/spawner.test.ts`, если ввёл особое правило спавна.
|
Добавь тест в `tests/spawner.test.ts`, если ввёл особое правило спавна.
|
||||||
@@ -111,9 +113,21 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. Кастомные ассеты (своя тема / текстуры)
|
## 8. Кастомные ассеты (спрайты, текстуры, тема)
|
||||||
|
|
||||||
Сейчас вид мира — это цвета в **`src/render/theme.ts`** (`Theme` + `DEFAULT_THEME`).
|
Графика грузится из PNG в **`src/assets/`** (класс `Assets` в `src/render/assets.ts`
|
||||||
|
тянет `assets/<ключ>.png` через `THREE.TextureLoader`); если файла нет — рисуется
|
||||||
|
процедурный фолбэк на canvas, и игра не ломается.
|
||||||
|
|
||||||
|
- **Свой/новый ассет:** просто положи PNG с именем `<ключ>.png` в **`src/assets/`**
|
||||||
|
(dev-сервер отдаёт их сразу, прод-сборка копирует в `dist/assets`). Код менять не нужно.
|
||||||
|
Ключи = именам файлов (`player-ranged`, `enemy-boss`, `floor`, `door-closed`, `tear`,
|
||||||
|
`heart-full`, `icon-ranged`, …); полный список и промпт для дизайн-ИИ — в **`docs/ASSET_BRIEF.md`**.
|
||||||
|
- **Запасной рисунок (фолбэк):** функции `drawX()` в `assets.ts` — правь их, только если
|
||||||
|
нужен другой плейсхолдер на случай отсутствия PNG.
|
||||||
|
- **Цвета/тинты/фон** (не текстуры) — в **`src/render/theme.ts`** (`Theme` + `DEFAULT_THEME`).
|
||||||
|
- **HUD-ассеты** (сердечки `heart-*`, иконки `icon-*`) грузит `HudOverlay`; меню (`logo`,
|
||||||
|
`menu-bg`) — `index.html`. Те же имена в `src/assets/`.
|
||||||
|
|
||||||
- **Своя палитра:** сделай ещё один объект `Theme` и передай его в
|
- **Своя палитра:** сделай ещё один объект `Theme` и передай его в
|
||||||
`new ThreeRenderer(canvas, myTheme)` в `main.ts`. Сейчас рендер создаётся один
|
`new ThreeRenderer(canvas, myTheme)` в `main.ts`. Сейчас рендер создаётся один
|
||||||
@@ -133,10 +147,11 @@
|
|||||||
Рендер изолирован за интерфейсом **`src/render/Renderer.ts`** (`render(game, alpha)`
|
Рендер изолирован за интерфейсом **`src/render/Renderer.ts`** (`render(game, alpha)`
|
||||||
+ `dispose()`). Варианты:
|
+ `dispose()`). Варианты:
|
||||||
|
|
||||||
- **Доработать вид** (спрайты, текстуры, частицы) — внутри `ThreeRenderer`.
|
- **Доработать вид** (спрайты, текстуры, частицы) — внутри `ThreeRenderer` (камера
|
||||||
- **Сделать 3D** — поменяй `OrthographicCamera` на `PerspectiveCamera`, добавь
|
уже наклонная `PerspectiveCamera`, сущности — биллборд-спрайты).
|
||||||
свет и 3D-меши. Мир рисуется по тем же координатам сущностей из `Game` — логику
|
- **Усилить 3D** — для настоящего объёма замени `MeshBasicMaterial` (он без света)
|
||||||
менять не нужно.
|
на `MeshStandardMaterial`, добавь источники света и объёмные меши вместо плоскостей.
|
||||||
|
Мир рисуется по тем же координатам сущностей из `Game` — логику менять не нужно.
|
||||||
- **Другой рендер целиком** (например, Canvas2D для отладки) — реализуй `Renderer`
|
- **Другой рендер целиком** (например, Canvas2D для отладки) — реализуй `Renderer`
|
||||||
и подставь в `main.ts`. Ядро не трогается вообще.
|
и подставь в `main.ts`. Ядро не трогается вообще.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Dungeon Crawl — three.js</title>
|
<title>Биндим Фигняшку</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -27,11 +27,14 @@
|
|||||||
/* Стартовое меню — поверх холстов, перекрывает сцену своим фоном. */
|
/* Стартовое меню — поверх холстов, перекрывает сцену своим фоном. */
|
||||||
#menu {
|
#menu {
|
||||||
position: absolute; inset: 0; z-index: 10;
|
position: absolute; inset: 0; z-index: 10;
|
||||||
background: #0a0a0fee;
|
/* фон-картинка с тёмным затемнением для читаемости; фолбэк — тёмный цвет */
|
||||||
|
background: linear-gradient(rgba(10,10,15,0.7), rgba(10,10,15,0.82)),
|
||||||
|
url(assets/menu-bg.png) center / cover no-repeat, #0a0a0f;
|
||||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
gap: 14px; padding: 24px;
|
gap: 14px; padding: 24px;
|
||||||
}
|
}
|
||||||
#menu h1 { font-size: 40px; letter-spacing: 4px; color: #c9b27a; }
|
#menu h1 { font-size: 40px; letter-spacing: 4px; color: #c9b27a; margin-bottom: 4px; }
|
||||||
|
#menu-logo { display: block; width: min(560px, 80%); height: auto; }
|
||||||
.menu-sub { color: #888; margin-bottom: 6px; }
|
.menu-sub { color: #888; margin-bottom: 6px; }
|
||||||
#menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; }
|
#menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; }
|
||||||
.menu-preset {
|
.menu-preset {
|
||||||
@@ -55,7 +58,7 @@
|
|||||||
|
|
||||||
<!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. -->
|
<!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. -->
|
||||||
<div id="menu">
|
<div id="menu">
|
||||||
<h1>DUNGEON CRAWL</h1>
|
<h1><img id="menu-logo" src="assets/logo.png" alt="Биндим Фигняшку" /></h1>
|
||||||
<p class="menu-sub">Выбери уровень</p>
|
<p class="menu-sub">Выбери уровень</p>
|
||||||
<div id="menu-presets"></div>
|
<div id="menu-presets"></div>
|
||||||
<p class="menu-hint">WASD — движение · Стрелки — стрельба · Tab — оружие · Esc — меню</p>
|
<p class="menu-hint">WASD — движение · Стрелки — стрельба · Tab — оружие · Esc — меню</p>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 934 B |
|
After Width: | Height: | Size: 621 B |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -28,6 +28,11 @@ export const OY = 80; // отступ комнаты сверху (м
|
|||||||
export const FIXED_FPS = 60;
|
export const FIXED_FPS = 60;
|
||||||
export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг
|
export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг
|
||||||
|
|
||||||
|
export const GAME_LOOP = {
|
||||||
|
maxFrameTimeSec: 0.25, // не «отыгрывать» долгие паузы (фон/таб)
|
||||||
|
maxStepsPerFrame: 5, // защита от «спирали смерти» при лагах
|
||||||
|
};
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// Типы тайлов
|
// Типы тайлов
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -88,6 +93,7 @@ export const PLAYER = {
|
|||||||
speed: 3.2, // px за шаг
|
speed: 3.2, // px за шаг
|
||||||
maxHp: 6,
|
maxHp: 6,
|
||||||
invFrames: 60, // неуязвимость после удара, в шагах
|
invFrames: 60, // неуязвимость после удара, в шагах
|
||||||
|
entryInvFrames: 20, // короткая неуязвимость при входе в комнату, в шагах
|
||||||
rangedCooldown: 10, // перезарядка выстрела, в шагах
|
rangedCooldown: 10, // перезарядка выстрела, в шагах
|
||||||
meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
|
meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
|
||||||
transitionLock: 15, // блок повторного перехода между комнатами, в шагах
|
transitionLock: 15, // блок повторного перехода между комнатами, в шагах
|
||||||
@@ -137,6 +143,7 @@ export const SPAWN = {
|
|||||||
minDistFromDoor: 180, // не спавнить ближе к двери входа
|
minDistFromDoor: 180, // не спавнить ближе к двери входа
|
||||||
minDistFromPlayer: 150,
|
minDistFromPlayer: 150,
|
||||||
minDistBetween: 60,
|
minDistBetween: 60,
|
||||||
|
maxPlacementTries: 100,
|
||||||
treasureChance: 0.12, // шанс комнаты-сокровищницы
|
treasureChance: 0.12, // шанс комнаты-сокровищницы
|
||||||
bossChance: 0.2, // шанс назначить комнату боссом
|
bossChance: 0.2, // шанс назначить комнату боссом
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export class Game {
|
|||||||
const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
|
const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
|
||||||
this.player.place(px, py);
|
this.player.place(px, py);
|
||||||
this.player.facing = fromDir;
|
this.player.facing = fromDir;
|
||||||
this.player.invTimer = 20; // короткая неуязвимость на входе
|
this.player.invTimer = PLAYER.entryInvFrames;
|
||||||
this.player.transCD = PLAYER.transitionLock;
|
this.player.transCD = PLAYER.transitionLock;
|
||||||
|
|
||||||
this.meleeSwing = null;
|
this.meleeSwing = null;
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export class Rng {
|
|||||||
private state: number;
|
private state: number;
|
||||||
|
|
||||||
/** Без seed — случайный старт; с seed — детерминированная цепочка. */
|
/** Без seed — случайный старт; с seed — детерминированная цепочка. */
|
||||||
constructor(seed?: number) {
|
constructor(seed: number = autoSeed()) {
|
||||||
// 0 — валидный seed, поэтому проверяем именно на undefined.
|
// 0 — валидный seed; авто-seed включается только когда аргумент не передан.
|
||||||
this.state = (seed === undefined ? (Math.random() * 2 ** 32) >>> 0 : seed) >>> 0;
|
this.state = seed >>> 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
|
/** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
|
||||||
@@ -43,6 +43,7 @@ export class Rng {
|
|||||||
|
|
||||||
/** Случайный элемент массива. */
|
/** Случайный элемент массива. */
|
||||||
pick<T>(arr: readonly T[]): T {
|
pick<T>(arr: readonly T[]): T {
|
||||||
|
if (arr.length === 0) throw new Error('Rng.pick: пустой массив');
|
||||||
return arr[this.int(0, arr.length - 1)];
|
return arr[this.int(0, arr.length - 1)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,3 +56,22 @@ export class Rng {
|
|||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTO_SEED_STEP = 0x9e3779b9;
|
||||||
|
let autoSeedCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed для нового нефиксированного забега. Энтропия берётся только внутри Rng:
|
||||||
|
* остальная игровая логика по-прежнему получает все случайные числа через next().
|
||||||
|
*/
|
||||||
|
function autoSeed(): number {
|
||||||
|
autoSeedCounter = (autoSeedCounter + AUTO_SEED_STEP) >>> 0;
|
||||||
|
let seed = Date.now() >>> 0;
|
||||||
|
const crypto = globalThis.crypto;
|
||||||
|
if (crypto?.getRandomValues) {
|
||||||
|
const value = new Uint32Array(1);
|
||||||
|
crypto.getRandomValues(value);
|
||||||
|
seed ^= value[0];
|
||||||
|
}
|
||||||
|
return (seed ^ autoSeedCounter) >>> 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ import type { Dir, EnemyType } from '../types';
|
|||||||
import type { Rng } from '../rng';
|
import type { Rng } from '../rng';
|
||||||
import { DEFAULT_RULES, type LevelRules } from '../rules';
|
import { DEFAULT_RULES, type LevelRules } from '../rules';
|
||||||
|
|
||||||
|
function isSpawnSpotClear(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
doorX: number,
|
||||||
|
doorY: number,
|
||||||
|
playerX: number,
|
||||||
|
playerY: number,
|
||||||
|
enemies: readonly Enemy[],
|
||||||
|
): boolean {
|
||||||
|
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) return false;
|
||||||
|
if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) return false;
|
||||||
|
for (const e of enemies) {
|
||||||
|
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подбирает врагов для комнаты и расставляет их так, чтобы они не появились
|
* Подбирает врагов для комнаты и расставляет их так, чтобы они не появились
|
||||||
* вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов
|
* вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов
|
||||||
@@ -43,19 +60,12 @@ export function spawnEnemies(
|
|||||||
let x = 0;
|
let x = 0;
|
||||||
let y = 0;
|
let y = 0;
|
||||||
let ok = false;
|
let ok = false;
|
||||||
for (let tries = 0; tries < 100 && !ok; tries++) {
|
for (let tries = 0; tries < SPAWN.maxPlacementTries && !ok; tries++) {
|
||||||
x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
|
x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
|
||||||
y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
|
y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
|
||||||
ok = true;
|
ok = isSpawnSpotClear(x, y, doorX, doorY, playerX, playerY, enemies);
|
||||||
|
|
||||||
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) ok = false;
|
|
||||||
else if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) ok = false;
|
|
||||||
else {
|
|
||||||
for (const e of enemies) {
|
|
||||||
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) { ok = false; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!ok) continue;
|
||||||
|
|
||||||
enemies.push(new Enemy(x, y, type, mods));
|
enemies.push(new Enemy(x, y, type, mods));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FIXED_DT } from '../config';
|
import { FIXED_DT, GAME_LOOP } from '../config';
|
||||||
import type { Game } from '../core/Game';
|
import type { Game } from '../core/Game';
|
||||||
import type { KeyboardController } from '../input/KeyboardController';
|
import type { InputSource } from '../input/InputState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Игровой цикл с ФИКСИРОВАННЫМ шагом.
|
* Игровой цикл с ФИКСИРОВАННЫМ шагом.
|
||||||
@@ -15,11 +15,10 @@ export class GameLoop {
|
|||||||
private last = 0;
|
private last = 0;
|
||||||
private rafId = 0;
|
private rafId = 0;
|
||||||
private running = false;
|
private running = false;
|
||||||
private readonly maxSteps = 5; // защита от «спирали смерти» при лагах
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly game: Game,
|
private readonly game: Game,
|
||||||
private readonly controller: KeyboardController,
|
private readonly controller: InputSource,
|
||||||
private readonly onRender: (alpha: number) => void,
|
private readonly onRender: (alpha: number) => void,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -31,16 +30,19 @@ export class GameLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
if (!this.running) return;
|
||||||
this.running = false;
|
this.running = false;
|
||||||
cancelAnimationFrame(this.rafId);
|
cancelAnimationFrame(this.rafId);
|
||||||
|
this.rafId = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private frame = (now: number): void => {
|
private frame = (now: number): void => {
|
||||||
|
if (!this.running) return;
|
||||||
this.rafId = requestAnimationFrame(this.frame);
|
this.rafId = requestAnimationFrame(this.frame);
|
||||||
|
|
||||||
let frameTime = (now - this.last) / 1000;
|
let frameTime = (now - this.last) / 1000;
|
||||||
this.last = now;
|
this.last = now;
|
||||||
if (frameTime > 0.25) frameTime = 0.25; // не «отыгрывать» долгие паузы (фон/таб)
|
if (frameTime > GAME_LOOP.maxFrameTimeSec) frameTime = GAME_LOOP.maxFrameTimeSec;
|
||||||
|
|
||||||
// Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр.
|
// Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр.
|
||||||
const input = this.controller.poll();
|
const input = this.controller.poll();
|
||||||
@@ -48,12 +50,12 @@ export class GameLoop {
|
|||||||
|
|
||||||
this.accumulator += frameTime;
|
this.accumulator += frameTime;
|
||||||
let steps = 0;
|
let steps = 0;
|
||||||
while (this.accumulator >= FIXED_DT && steps < this.maxSteps) {
|
while (this.accumulator >= FIXED_DT && steps < GAME_LOOP.maxStepsPerFrame) {
|
||||||
this.game.step(input);
|
this.game.step(input);
|
||||||
this.accumulator -= FIXED_DT;
|
this.accumulator -= FIXED_DT;
|
||||||
steps++;
|
steps++;
|
||||||
}
|
}
|
||||||
if (steps === this.maxSteps) this.accumulator = 0; // отстали — ресинхронизируемся
|
if (steps === GAME_LOOP.maxStepsPerFrame) this.accumulator = 0; // отстали — ресинхронизируемся
|
||||||
|
|
||||||
const alpha = this.accumulator / FIXED_DT;
|
const alpha = this.accumulator / FIXED_DT;
|
||||||
this.onRender(alpha);
|
this.onRender(alpha);
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export interface InputState {
|
|||||||
restart: boolean; // рестарт на экране конца игры (однократно)
|
restart: boolean; // рестарт на экране конца игры (однократно)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */
|
||||||
|
export interface InputSource {
|
||||||
|
poll(): InputState;
|
||||||
|
}
|
||||||
|
|
||||||
/** Нейтральный снимок — ничего не нажато. */
|
/** Нейтральный снимок — ничего не нажато. */
|
||||||
export function emptyInput(): InputState {
|
export function emptyInput(): InputState {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,33 +1,38 @@
|
|||||||
import type { InputState } from './InputState';
|
import type { InputSource, InputState } from './InputState';
|
||||||
import type { Dir } from '../core/types';
|
import type { Dir } from '../core/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Раскладка: WASD — движение, стрелки — прицельная стрельба, пробел —
|
* Раскладка: WASD — движение, стрелки — прицельная стрельба, пробел —
|
||||||
* атака по ходу движения, Tab/Q — смена оружия, R — рестарт.
|
* атака по ходу движения, Tab/Q — смена оружия, R — рестарт.
|
||||||
*
|
*
|
||||||
* Контроллер держит набор зажатых клавиш и «защёлкивает» однократные
|
* ВАЖНО: используем `event.code` (ФИЗИЧЕСКАЯ клавиша), а не `event.key`.
|
||||||
* действия (смена оружия/рестарт). Раз в кадр вызывается poll(), который
|
* `code` не зависит от раскладки, поэтому WASD/Q/R работают и в русской
|
||||||
* собирает InputState и сбрасывает однократные флаги.
|
* раскладке (где те же клавиши дают «цфыв»/«й»/«к»). Стрелки/Tab/пробел в
|
||||||
|
* `code` называются ArrowUp/Tab/Space.
|
||||||
|
*
|
||||||
|
* Контроллер держит набор зажатых клавиш и «защёлкивает» однократные действия
|
||||||
|
* (смена оружия/рестарт). Раз в кадр вызывается poll(), который собирает
|
||||||
|
* InputState и сбрасывает однократные флаги.
|
||||||
*/
|
*/
|
||||||
export class KeyboardController {
|
export class KeyboardController implements InputSource {
|
||||||
private held = new Set<string>();
|
private held = new Set<string>();
|
||||||
private toggleWeaponEdge = false;
|
private toggleWeaponEdge = false;
|
||||||
private restartEdge = false;
|
private restartEdge = false;
|
||||||
private attached = false;
|
private attached = false;
|
||||||
|
|
||||||
private onKeyDown = (e: KeyboardEvent): void => {
|
private onKeyDown = (e: KeyboardEvent): void => {
|
||||||
const k = e.key;
|
const c = e.code;
|
||||||
// Однократные действия ловим по факту нажатия (не по удержанию).
|
// Однократные действия ловим по факту нажатия (не по удержанию).
|
||||||
if (!this.held.has(k)) {
|
if (!this.held.has(c)) {
|
||||||
if (k === 'Tab' || k === 'q' || k === 'Q') this.toggleWeaponEdge = true;
|
if (c === 'Tab' || c === 'KeyQ') this.toggleWeaponEdge = true;
|
||||||
if (k === 'r' || k === 'R') this.restartEdge = true;
|
if (c === 'KeyR') this.restartEdge = true;
|
||||||
}
|
}
|
||||||
this.held.add(k);
|
this.held.add(c);
|
||||||
if (PREVENT.has(k)) e.preventDefault();
|
if (PREVENT.has(c)) e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
private onKeyUp = (e: KeyboardEvent): void => {
|
private onKeyUp = (e: KeyboardEvent): void => {
|
||||||
this.held.delete(e.key);
|
this.held.delete(e.code);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onBlur = (): void => {
|
private onBlur = (): void => {
|
||||||
@@ -57,14 +62,14 @@ export class KeyboardController {
|
|||||||
|
|
||||||
/** Собрать снимок ввода и сбросить однократные флаги. */
|
/** Собрать снимок ввода и сбросить однократные флаги. */
|
||||||
poll(): InputState {
|
poll(): InputState {
|
||||||
const down = (k: string) => this.held.has(k);
|
const down = (code: string) => this.held.has(code);
|
||||||
|
|
||||||
let moveX = 0;
|
let moveX = 0;
|
||||||
let moveY = 0;
|
let moveY = 0;
|
||||||
if (down('w') || down('W')) moveY -= 1;
|
if (down('KeyW')) moveY -= 1;
|
||||||
if (down('s') || down('S')) moveY += 1;
|
if (down('KeyS')) moveY += 1;
|
||||||
if (down('a') || down('A')) moveX -= 1;
|
if (down('KeyA')) moveX -= 1;
|
||||||
if (down('d') || down('D')) moveX += 1;
|
if (down('KeyD')) moveX += 1;
|
||||||
|
|
||||||
let aimDir: Dir | null = null;
|
let aimDir: Dir | null = null;
|
||||||
if (down('ArrowUp')) aimDir = 'up';
|
if (down('ArrowUp')) aimDir = 'up';
|
||||||
@@ -76,7 +81,7 @@ export class KeyboardController {
|
|||||||
moveX,
|
moveX,
|
||||||
moveY,
|
moveY,
|
||||||
aimDir,
|
aimDir,
|
||||||
attackHeld: down(' ') || down('Spacebar'),
|
attackHeld: down('Space'),
|
||||||
toggleWeapon: this.toggleWeaponEdge,
|
toggleWeapon: this.toggleWeaponEdge,
|
||||||
restart: this.restartEdge,
|
restart: this.restartEdge,
|
||||||
};
|
};
|
||||||
@@ -87,7 +92,7 @@ export class KeyboardController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Клавиши, у которых гасим стандартное поведение браузера (скролл и т.п.). */
|
/** Физические клавиши (e.code), у которых гасим поведение браузера (скролл/таб). */
|
||||||
const PREVENT = new Set([
|
const PREVENT = new Set([
|
||||||
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'Spacebar', 'Tab',
|
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ function boot(): void {
|
|||||||
const menu = new StartMenu(menuEl, PRESETS, startGame);
|
const menu = new StartMenu(menuEl, PRESETS, startGame);
|
||||||
menu.show();
|
menu.show();
|
||||||
|
|
||||||
// Esc во время игры — вернуться к выбору уровня.
|
// Esc во время игры — вернуться к выбору уровня (по физической клавише).
|
||||||
window.addEventListener('keydown', (e) => {
|
window.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Escape' && loop) {
|
if (e.code === 'Escape' && loop) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toMenu();
|
toMenu();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ import type { Renderer } from './Renderer';
|
|||||||
*/
|
*/
|
||||||
export class HudOverlay implements Renderer {
|
export class HudOverlay implements Renderer {
|
||||||
private readonly ctx: CanvasRenderingContext2D;
|
private readonly ctx: CanvasRenderingContext2D;
|
||||||
|
private readonly images = new Map<string, HTMLImageElement>();
|
||||||
|
|
||||||
|
/** Лениво грузит PNG из assets/<name>.png; возвращает картинку, только когда она готова. */
|
||||||
|
private img(name: string): HTMLImageElement | null {
|
||||||
|
let im = this.images.get(name);
|
||||||
|
if (!im) {
|
||||||
|
im = new Image();
|
||||||
|
im.src = `assets/${name}.png`;
|
||||||
|
this.images.set(name, im);
|
||||||
|
}
|
||||||
|
return im.complete && im.naturalWidth > 0 ? im : null;
|
||||||
|
}
|
||||||
|
|
||||||
constructor(canvas: HTMLCanvasElement) {
|
constructor(canvas: HTMLCanvasElement) {
|
||||||
// Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI),
|
// Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI),
|
||||||
@@ -27,8 +39,8 @@ export class HudOverlay implements Renderer {
|
|||||||
this.drawHud(game);
|
this.drawHud(game);
|
||||||
this.drawMinimap(game);
|
this.drawMinimap(game);
|
||||||
|
|
||||||
if (game.gameOver) this.drawOverlay('#c33', 'GAME OVER');
|
if (game.gameOver) this.drawOverlay('#c33', 'ИГРА ОКОНЧЕНА');
|
||||||
else if (game.won) this.drawOverlay('#3c3', 'VICTORY');
|
else if (game.won) this.drawOverlay('#3c3', 'ПОБЕДА');
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
@@ -40,31 +52,26 @@ export class HudOverlay implements Renderer {
|
|||||||
const p = game.player;
|
const p = game.player;
|
||||||
const room = game.curRoom;
|
const room = game.curRoom;
|
||||||
|
|
||||||
// Полоса здоровья.
|
// Здоровье: сердечки (2 HP = сердце), с фолбэком на полосу.
|
||||||
const bx = 20, by = 20, bw = 140, bh = 14;
|
const healthBottom = this.drawHealth(p.hp, p.maxHp);
|
||||||
ctx.fillStyle = '#111'; ctx.fillRect(bx, by, bw, bh);
|
|
||||||
ctx.fillStyle = '#2a0a0a'; ctx.fillRect(bx + 2, by + 2, bw - 4, bh - 4);
|
|
||||||
const hpRatio = Math.max(0, p.hp / p.maxHp);
|
|
||||||
ctx.fillStyle = hpRatio > 0.5 ? '#993333' : hpRatio > 0.25 ? '#994422' : '#663322';
|
|
||||||
ctx.fillRect(bx + 2, by + 2, (bw - 4) * hpRatio, bh - 4);
|
|
||||||
ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.strokeRect(bx, by, bw, bh);
|
|
||||||
ctx.fillStyle = '#bbb'; ctx.font = '10px monospace'; ctx.textAlign = 'center';
|
|
||||||
ctx.fillText(`HP ${p.hp}/${p.maxHp}`, bx + bw / 2, by + bh - 3);
|
|
||||||
|
|
||||||
// Название текущего уровня (правил).
|
// Название текущего уровня (правил).
|
||||||
ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace';
|
ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace';
|
||||||
ctx.fillText(`Уровень: ${game.rules.name}`, bx, by + bh + 14);
|
ctx.fillText(`Уровень: ${game.rules.name}`, 20, healthBottom + 14);
|
||||||
|
|
||||||
// Индикатор режима боя.
|
// Индикатор режима боя.
|
||||||
const my = CH - 46;
|
const my = CH - 46;
|
||||||
const ranged = p.mode === MODE_RANGED;
|
const ranged = p.mode === MODE_RANGED;
|
||||||
const mText = ranged ? 'RANGED' : 'MELEE';
|
const mText = ranged ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ';
|
||||||
const mCol = ranged ? '#4488cc' : '#cc6644';
|
const mCol = ranged ? '#4488cc' : '#cc6644';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
|
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
|
||||||
ctx.strokeStyle = mCol; ctx.lineWidth = 2; ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
|
ctx.strokeStyle = mCol; ctx.lineWidth = 2; ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
|
||||||
ctx.fillStyle = mCol; ctx.font = 'bold 17px monospace'; ctx.fillText(`[ ${mText} ]`, CW / 2, my + 8);
|
ctx.fillStyle = mCol; ctx.font = 'bold 17px monospace'; ctx.fillText(`[ ${mText} ]`, CW / 2, my + 8);
|
||||||
ctx.fillStyle = '#555'; ctx.font = '11px monospace'; ctx.fillText('[Tab] сменить оружие', CW / 2, my - 26);
|
ctx.fillStyle = '#555'; ctx.font = '11px monospace'; ctx.fillText('[Tab] сменить оружие', CW / 2, my - 26);
|
||||||
|
// Иконка оружия слева в рамке (если ассет есть).
|
||||||
|
const icon = this.img(ranged ? 'icon-ranged' : 'icon-melee');
|
||||||
|
if (icon) ctx.drawImage(icon, CW / 2 - 90, my - 14, 26, 26);
|
||||||
|
|
||||||
// Счётчик врагов / подсказка зачистки.
|
// Счётчик врагов / подсказка зачистки.
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
@@ -87,6 +94,36 @@ export class HudOverlay implements Renderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Рисует здоровье сердечками (2 HP = сердце); фолбэк — полоса. Возвращает нижний Y. */
|
||||||
|
private drawHealth(hp: number, maxHp: number): number {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const x0 = 20, y0 = 16;
|
||||||
|
const full = this.img('heart-full');
|
||||||
|
const half = this.img('heart-half');
|
||||||
|
const empty = this.img('heart-empty');
|
||||||
|
if (full && half && empty) {
|
||||||
|
const sz = 26, gap = 2;
|
||||||
|
const slots = Math.ceil(maxHp / 2);
|
||||||
|
for (let i = 0; i < slots; i++) {
|
||||||
|
const rem = hp - i * 2;
|
||||||
|
const im = rem >= 2 ? full : rem === 1 ? half : empty;
|
||||||
|
ctx.drawImage(im, x0 + i * (sz + gap), y0, sz, sz);
|
||||||
|
}
|
||||||
|
return y0 + sz;
|
||||||
|
}
|
||||||
|
// Фолбэк — полоса HP.
|
||||||
|
const by = 20, bw = 140, bh = 14;
|
||||||
|
ctx.fillStyle = '#111'; ctx.fillRect(x0, by, bw, bh);
|
||||||
|
ctx.fillStyle = '#2a0a0a'; ctx.fillRect(x0 + 2, by + 2, bw - 4, bh - 4);
|
||||||
|
const ratio = Math.max(0, hp / maxHp);
|
||||||
|
ctx.fillStyle = ratio > 0.5 ? '#993333' : ratio > 0.25 ? '#994422' : '#663322';
|
||||||
|
ctx.fillRect(x0 + 2, by + 2, (bw - 4) * ratio, bh - 4);
|
||||||
|
ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.strokeRect(x0, by, bw, bh);
|
||||||
|
ctx.fillStyle = '#bbb'; ctx.font = '10px monospace'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(`HP ${hp}/${maxHp}`, x0 + bw / 2, by + bh - 3);
|
||||||
|
return by + bh;
|
||||||
|
}
|
||||||
|
|
||||||
private drawMinimap(game: Game): void {
|
private drawMinimap(game: Game): void {
|
||||||
const ctx = this.ctx;
|
const ctx = this.ctx;
|
||||||
const ox = CW - 180, oy = 12, cell = 14, gap = 2, cs = cell + gap;
|
const ox = CW - 180, oy = 12, cell = 14, gap = 2, cs = cell + gap;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import {
|
import {
|
||||||
CW, CH, OX, OY, TILE, COLS, ROWS,
|
CW, CH, OX, OY, TILE, COLS, ROWS, RW, RH,
|
||||||
T_WALL, T_DOOR, MODE_RANGED, PROJECTILE,
|
DIR, MODE_RANGED, PROJECTILE, MELEE,
|
||||||
} from '../config';
|
} from '../config';
|
||||||
import { lerp } from '../core/util';
|
import { lerp } from '../core/util';
|
||||||
import type { Game } from '../core/Game';
|
import type { Game } from '../core/Game';
|
||||||
@@ -10,137 +10,213 @@ import type { Enemy } from '../core/entities/Enemy';
|
|||||||
import type { Projectile } from '../core/entities/Projectile';
|
import type { Projectile } from '../core/entities/Projectile';
|
||||||
import type { Renderer } from './Renderer';
|
import type { Renderer } from './Renderer';
|
||||||
import { DEFAULT_THEME, type Theme } from './theme';
|
import { DEFAULT_THEME, type Theme } from './theme';
|
||||||
|
import { Assets, type SpriteKey } from './assets';
|
||||||
/** Z-слои: больше значение — ближе к камере (рисуется поверх). */
|
|
||||||
const Z = { floor: 0, wall: 1, door: 0.5, swing: 4, entity: 5, tear: 6 };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Все материалы — DoubleSide. Наша ортокамера переворачивает ось Y
|
* Псевдо-3D рендер «как в Isaac»: наклонная перспективная камера, пол лежит
|
||||||
* (top=0 сверху), из-за чего инвертируется порядок вершин и при обычном
|
* плоско, а персонажи/враги — ВЕРТИКАЛЬНЫЕ спрайты-биллборды, стоящие на полу.
|
||||||
* отсечении задних граней плоскости становятся невидимыми. DoubleSide
|
|
||||||
* рисует грань с обеих сторон — для плоского 2D это правильный выбор.
|
|
||||||
*/
|
|
||||||
function flatMat(params: THREE.MeshBasicMaterialParameters = {}): THREE.MeshBasicMaterial {
|
|
||||||
return new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, ...params });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Рендер мира на three.js с ОРТОГРАФИЧЕСКОЙ камерой: 3D-движок, но картинка
|
|
||||||
* плоская 2D-сверху (как у настоящего Isaac). Мировые координаты совпадают
|
|
||||||
* с пиксельными координатами логики (x вправо, y вниз), поэтому вся
|
|
||||||
* математика ядра остаётся валидной без пересчётов.
|
|
||||||
*
|
*
|
||||||
* Управление ресурсами:
|
* КАРТА КООРДИНАТ: игровая логика остаётся 2D-сверху (x, y). В 3D мы кладём
|
||||||
* • геометрии-«единицы» (unitPlane/unitCircle) общие и переиспользуются
|
* y на ось Z: мировая точка = (x, высота, y). Пол — плоскость Y=0; вверх — +Y.
|
||||||
* масштабированием — не плодим геометрии;
|
* Поэтому ВСЯ математика и КОЛЛИЗИИ ядра без изменений: хитбоксы по-прежнему на
|
||||||
* • тайлы комнаты пересобираются ТОЛЬКО при смене комнаты;
|
* полу (footprint спрайта), псевдо-3D — чисто визуальный слой.
|
||||||
* • меши сущностей создаются/удаляются по мере появления/исчезновения
|
*
|
||||||
* (mark-and-sweep), их персональные материалы корректно dispose-ятся.
|
* Камера фиксированная на комнату (как в Isaac), кадрирует всю комнату.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ── Параметры вида (крути для настройки картинки) ─────────────
|
||||||
|
const WALL_H = 38; // высота стен
|
||||||
|
const SPRITE_SCALE = 1.5; // ширина спрайта = размер хитбокса × это
|
||||||
|
const SPRITE_ASPECT = 64 / 48; // высота/ширина спрайта (из канваса ассета)
|
||||||
|
const SHADOW_Y = 0.6; // тень чуть над полом (без z-fighting)
|
||||||
|
const TEAR_Y = 13; // высота полёта снаряда
|
||||||
|
const GAP = 3 * TILE; // ширина дверного проёма (3 тайла)
|
||||||
|
|
||||||
|
type EnemyVisual = { sprite: THREE.Mesh; shadow: THREE.Mesh; lastHit: number };
|
||||||
|
type Effect = { mesh: THREE.Mesh; life: number; max: number; vy: number; grow: number };
|
||||||
|
|
||||||
export class ThreeRenderer implements Renderer {
|
export class ThreeRenderer implements Renderer {
|
||||||
private readonly renderer: THREE.WebGLRenderer;
|
private readonly renderer: THREE.WebGLRenderer;
|
||||||
private readonly scene = new THREE.Scene();
|
private readonly scene = new THREE.Scene();
|
||||||
private readonly camera: THREE.OrthographicCamera;
|
private readonly camera: THREE.PerspectiveCamera;
|
||||||
|
private readonly assets = new Assets();
|
||||||
|
private readonly theme: Theme;
|
||||||
|
|
||||||
// Общие геометрии-единицы (масштабируем под размер сущности).
|
// Общие геометрии.
|
||||||
private readonly unitPlane = new THREE.PlaneGeometry(1, 1);
|
private readonly vGeo = new THREE.PlaneGeometry(1, 1); // вертикальный спрайт/стена
|
||||||
private readonly unitCircle = new THREE.CircleGeometry(0.5, 24);
|
private readonly flatGeo = new THREE.PlaneGeometry(1, 1); // лежит на полу (повёрнут)
|
||||||
|
|
||||||
// Общие материалы тайлов (без пер-тайлового мигания — можно шарить).
|
// Общие материалы.
|
||||||
private readonly tileMats: Record<string, THREE.MeshBasicMaterial>;
|
private readonly floorMat: THREE.MeshBasicMaterial;
|
||||||
|
private readonly wallMat: THREE.MeshBasicMaterial;
|
||||||
|
private readonly shadowMat: THREE.MeshBasicMaterial;
|
||||||
|
private readonly playerMat: Record<'ranged' | 'melee', THREE.MeshBasicMaterial>;
|
||||||
|
private readonly enemyMatKey: Record<Enemy['type'], SpriteKey> = {
|
||||||
|
normal: 'enemy-normal', fast: 'enemy-fast', boss: 'enemy-boss',
|
||||||
|
};
|
||||||
|
|
||||||
// Группа статичных тайлов текущей комнаты.
|
// Группа статичной геометрии комнаты (пол + стены + двери).
|
||||||
private roomGroup = new THREE.Group();
|
private roomGroup = new THREE.Group();
|
||||||
private renderedRoom: Room | null = null;
|
private renderedRoom: Room | null = null;
|
||||||
|
private renderedCleared = false;
|
||||||
|
|
||||||
// Динамические меши с персональными материалами.
|
// Динамика.
|
||||||
private readonly playerMesh: THREE.Mesh;
|
private readonly playerMesh: THREE.Mesh;
|
||||||
private readonly enemyMeshes = new Map<Enemy, THREE.Mesh>();
|
private readonly playerShadow: THREE.Mesh;
|
||||||
|
private readonly enemyVisuals = new Map<Enemy, EnemyVisual>();
|
||||||
private readonly tearMeshes = new Map<Projectile, THREE.Mesh>();
|
private readonly tearMeshes = new Map<Projectile, THREE.Mesh>();
|
||||||
private readonly swingMesh: THREE.Mesh;
|
private readonly swingMesh: THREE.Mesh;
|
||||||
|
private readonly effects: Effect[] = [];
|
||||||
private readonly theme: Theme;
|
private lastAtkCD = 0;
|
||||||
|
|
||||||
constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) {
|
constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) {
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
this.renderer.setSize(CW, CH, false);
|
this.renderer.setSize(CW, CH, false);
|
||||||
this.renderer.setClearColor(this.theme.bg, 1);
|
this.renderer.setClearColor(theme.bg, 1);
|
||||||
|
|
||||||
// Ортокамера: world (0,0) — верхний левый угол, (CW,CH) — нижний правый.
|
// Наклонная камера, кадрирует комнату с юга-сверху.
|
||||||
this.camera = new THREE.OrthographicCamera(0, CW, 0, CH, 0.1, 1000);
|
this.camera = new THREE.PerspectiveCamera(44, CW / CH, 1, 4000);
|
||||||
this.camera.position.z = 100;
|
const cx = OX + RW / 2;
|
||||||
|
const cz = OY + RH / 2;
|
||||||
|
// Наклонный «исааковский» ракурс: камера приподнята и отодвинута на юг.
|
||||||
|
this.camera.position.set(cx, 650, cz + 560);
|
||||||
|
this.camera.lookAt(cx, 40, cz);
|
||||||
|
|
||||||
this.tileMats = {
|
// Материалы.
|
||||||
floorA: flatMat({ color: this.theme.floorA }),
|
const floorTex = this.assets.floor();
|
||||||
floorB: flatMat({ color: this.theme.floorB }),
|
floorTex.wrapS = floorTex.wrapT = THREE.RepeatWrapping;
|
||||||
wall: flatMat({ color: this.theme.wall }),
|
floorTex.repeat.set(COLS, ROWS);
|
||||||
door: flatMat({ color: this.theme.door }),
|
this.floorMat = new THREE.MeshBasicMaterial({ map: floorTex });
|
||||||
};
|
|
||||||
|
const wallTex = this.assets.wall();
|
||||||
|
wallTex.wrapS = wallTex.wrapT = THREE.RepeatWrapping;
|
||||||
|
wallTex.repeat.set(4, 1);
|
||||||
|
this.wallMat = new THREE.MeshBasicMaterial({ map: wallTex, side: THREE.DoubleSide });
|
||||||
|
|
||||||
|
this.shadowMat = new THREE.MeshBasicMaterial({
|
||||||
|
map: this.assets.shadow(), transparent: true, depthWrite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const spriteMat = (key: SpriteKey) =>
|
||||||
|
new THREE.MeshBasicMaterial({ map: this.assets.sprite(key), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide });
|
||||||
|
this.playerMat = { ranged: spriteMat('player-ranged'), melee: spriteMat('player-melee') };
|
||||||
|
|
||||||
this.scene.add(this.roomGroup);
|
this.scene.add(this.roomGroup);
|
||||||
|
|
||||||
this.playerMesh = new THREE.Mesh(this.unitPlane, flatMat({ color: this.theme.playerRanged }));
|
this.playerShadow = this.flatMesh(this.shadowMat);
|
||||||
this.playerMesh.position.z = Z.entity;
|
this.scene.add(this.playerShadow);
|
||||||
|
this.playerMesh = new THREE.Mesh(this.vGeo, this.playerMat.ranged);
|
||||||
this.scene.add(this.playerMesh);
|
this.scene.add(this.playerMesh);
|
||||||
|
|
||||||
this.swingMesh = new THREE.Mesh(
|
this.swingMesh = this.flatMesh(
|
||||||
this.unitPlane,
|
new THREE.MeshBasicMaterial({
|
||||||
flatMat({ color: this.theme.swing, transparent: true, opacity: 0.45 }),
|
map: this.assets.spark(), color: new THREE.Color(this.theme.swing),
|
||||||
|
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
this.swingMesh.position.z = Z.swing;
|
|
||||||
this.swingMesh.visible = false;
|
this.swingMesh.visible = false;
|
||||||
this.scene.add(this.swingMesh);
|
this.scene.add(this.swingMesh);
|
||||||
}
|
}
|
||||||
|
|
||||||
render(game: Game, alpha: number): void {
|
render(game: Game, alpha: number): void {
|
||||||
const room = game.curRoom;
|
const room = game.curRoom;
|
||||||
if (room !== this.renderedRoom) this.buildRoom(room);
|
if (room !== this.renderedRoom || room.cleared !== this.renderedCleared) {
|
||||||
|
this.buildRoom(room);
|
||||||
|
}
|
||||||
|
|
||||||
this.syncPlayer(game, alpha);
|
this.syncPlayer(game, alpha);
|
||||||
this.syncEnemies(room, alpha);
|
this.syncEnemies(room, alpha);
|
||||||
this.syncTears(room, alpha);
|
this.syncTears(room, alpha);
|
||||||
this.syncSwing(game);
|
this.syncSwing(game);
|
||||||
|
this.updateEffects();
|
||||||
|
|
||||||
this.renderer.render(this.scene, this.camera);
|
this.renderer.render(this.scene, this.camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Статичная геометрия комнаты ───────────────────────────
|
// ── Статика комнаты: пол, стены, двери ────────────────────
|
||||||
|
|
||||||
private buildRoom(room: Room): void {
|
private buildRoom(room: Room): void {
|
||||||
this.clearGroup(this.roomGroup);
|
this.clearGroup(this.roomGroup);
|
||||||
this.renderedRoom = room;
|
this.renderedRoom = room;
|
||||||
|
this.renderedCleared = room.cleared;
|
||||||
|
|
||||||
for (let r = 0; r < ROWS; r++) {
|
// Пол — одна плоскость на весь периметр, ПЛАШМЯ (flatMesh кладёт её
|
||||||
for (let c = 0; c < COLS; c++) {
|
// горизонтально; без этого пол стоял бы вертикально и перспектива «выворачивалась»).
|
||||||
const t = room.tiles[r][c];
|
const floor = this.flatMesh(this.floorMat);
|
||||||
let mat: THREE.MeshBasicMaterial;
|
floor.scale.set(RW, RH, 1);
|
||||||
let z = Z.floor;
|
floor.position.set(OX + RW / 2, 0, OY + RH / 2);
|
||||||
if (t === T_WALL) { mat = this.tileMats.wall; z = Z.wall; }
|
this.roomGroup.add(floor);
|
||||||
else if (t === T_DOOR) { mat = this.tileMats.door; z = Z.door; }
|
|
||||||
else { mat = (r + c) % 2 === 0 ? this.tileMats.floorA : this.tileMats.floorB; }
|
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(this.unitPlane, mat);
|
// Стены по сторонам с проёмами под двери.
|
||||||
mesh.scale.set(TILE, TILE, 1);
|
const xGap: [number, number] | null =
|
||||||
mesh.position.set(OX + c * TILE + TILE / 2, OY + r * TILE + TILE / 2, z);
|
room.doors.up || room.doors.down ? [OX + 6 * TILE, OX + 6 * TILE + GAP] : null;
|
||||||
this.roomGroup.add(mesh);
|
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 {
|
private syncPlayer(game: Game, alpha: number): void {
|
||||||
const p = game.player;
|
const p = game.player;
|
||||||
const x = lerp(p.prevX, p.x, alpha);
|
const x = lerp(p.prevX, p.x, alpha);
|
||||||
const y = lerp(p.prevY, p.y, alpha);
|
const z = lerp(p.prevY, p.y, alpha);
|
||||||
this.playerMesh.position.set(x, y, Z.entity);
|
|
||||||
this.playerMesh.scale.set(p.w, p.h, 1);
|
|
||||||
|
|
||||||
const base = p.mode === MODE_RANGED ? this.theme.playerRanged : this.theme.playerMelee;
|
this.playerMesh.material = p.mode === MODE_RANGED ? this.playerMat.ranged : this.playerMat.melee;
|
||||||
const flashing = p.invTimer > 0 && p.invTimer % 6 < 3;
|
const w = p.w * SPRITE_SCALE;
|
||||||
(this.playerMesh.material as THREE.MeshBasicMaterial).color.setHex(flashing ? this.theme.flash : base);
|
const h = w * SPRITE_ASPECT;
|
||||||
|
this.playerMesh.scale.set(w, h, 1);
|
||||||
|
this.playerMesh.position.set(x, h / 2, z);
|
||||||
|
this.placeShadow(this.playerShadow, x, z, p.w);
|
||||||
|
|
||||||
|
// I-frames: мигаем спрайтом (классические кадры неуязвимости).
|
||||||
|
this.playerMesh.visible = !(p.invTimer > 0 && p.invTimer % 6 < 3);
|
||||||
|
|
||||||
|
// Вспышка из дула при выстреле (atkCD «подскочил» вверх).
|
||||||
|
if (p.mode === MODE_RANGED && p.atkCD > this.lastAtkCD) {
|
||||||
|
const [dx, dz] = DIR[p.facing];
|
||||||
|
this.spawnEffect(this.assets.muzzle(), this.theme.flash,
|
||||||
|
x + dx * (p.w * 0.7), h * 0.55, z + dz * (p.w * 0.7), 16, 6, { vy: 0, grow: 1.04 });
|
||||||
|
}
|
||||||
|
this.lastAtkCD = p.atkCD;
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncEnemies(room: Room, alpha: number): void {
|
private syncEnemies(room: Room, alpha: number): void {
|
||||||
@@ -149,25 +225,44 @@ export class ThreeRenderer implements Renderer {
|
|||||||
if (!e.alive) continue;
|
if (!e.alive) continue;
|
||||||
live.add(e);
|
live.add(e);
|
||||||
|
|
||||||
let mesh = this.enemyMeshes.get(e);
|
let v = this.enemyVisuals.get(e);
|
||||||
if (!mesh) {
|
if (!v) {
|
||||||
const geo = e.type === 'normal' ? this.unitPlane : this.unitCircle;
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
mesh = new THREE.Mesh(geo, flatMat());
|
map: this.assets.sprite(this.enemyMatKey[e.type]), transparent: true, alphaTest: 0.5, side: THREE.DoubleSide,
|
||||||
mesh.position.z = Z.entity;
|
});
|
||||||
this.scene.add(mesh);
|
const sprite = new THREE.Mesh(this.vGeo, mat);
|
||||||
this.enemyMeshes.set(e, mesh);
|
const shadow = this.flatMesh(this.shadowMat);
|
||||||
|
this.scene.add(sprite, shadow);
|
||||||
|
v = { sprite, shadow, lastHit: 0 };
|
||||||
|
this.enemyVisuals.set(e, v);
|
||||||
}
|
}
|
||||||
|
|
||||||
const x = lerp(e.prevX, e.x, alpha);
|
const x = lerp(e.prevX, e.x, alpha);
|
||||||
const y = lerp(e.prevY, e.y, alpha);
|
const z = lerp(e.prevY, e.y, alpha);
|
||||||
mesh.position.set(x, y, Z.entity);
|
const pop = 1 + 0.18 * (e.hitTimer / Math.max(1, MELEE.life)); // «дёргается» при попадании
|
||||||
mesh.scale.set(e.w, e.h, 1);
|
const w = e.w * SPRITE_SCALE * pop;
|
||||||
|
const h = w * SPRITE_ASPECT;
|
||||||
|
v.sprite.scale.set(w, h, 1);
|
||||||
|
v.sprite.position.set(x, h / 2, z);
|
||||||
|
this.placeShadow(v.shadow, x, z, e.w);
|
||||||
|
|
||||||
const base = e.type === 'fast' ? this.theme.enemyFast : e.type === 'boss' ? this.theme.enemyBoss : this.theme.enemyNormal;
|
// Искра в момент попадания (hitTimer вырос).
|
||||||
const flashing = e.hitTimer > 0 && e.hitTimer % 4 < 2;
|
if (e.hitTimer > v.lastHit) {
|
||||||
(mesh.material as THREE.MeshBasicMaterial).color.setHex(flashing ? this.theme.flash : base);
|
this.spawnEffect(this.assets.spark(), this.theme.flash, x, e.w * 0.6, z, e.w * 0.9, 8, { vy: 0.6, grow: 1.06 });
|
||||||
|
}
|
||||||
|
v.lastHit = e.hitTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уборка: исчезнувшие враги. Если враг мёртв — «пуф» на месте гибели.
|
||||||
|
for (const [e, v] of this.enemyVisuals) {
|
||||||
|
if (live.has(e)) continue;
|
||||||
|
if (!e.alive) {
|
||||||
|
this.spawnEffect(this.assets.puff(), 0xffffff, e.x, e.w * 0.6, e.y, e.w * 1.2, 14, { vy: 1.1, grow: 1.07 });
|
||||||
|
}
|
||||||
|
this.scene.remove(v.sprite, v.shadow);
|
||||||
|
(v.sprite.material as THREE.Material).dispose();
|
||||||
|
this.enemyVisuals.delete(e);
|
||||||
}
|
}
|
||||||
this.sweep(this.enemyMeshes, live);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncTears(room: Room, alpha: number): void {
|
private syncTears(room: Room, alpha: number): void {
|
||||||
@@ -175,56 +270,106 @@ export class ThreeRenderer implements Renderer {
|
|||||||
for (const t of room.tears) {
|
for (const t of room.tears) {
|
||||||
if (!t.alive) continue;
|
if (!t.alive) continue;
|
||||||
live.add(t);
|
live.add(t);
|
||||||
|
|
||||||
let mesh = this.tearMeshes.get(t);
|
let mesh = this.tearMeshes.get(t);
|
||||||
if (!mesh) {
|
if (!mesh) {
|
||||||
mesh = new THREE.Mesh(this.unitCircle, flatMat({ color: this.theme.tear }));
|
mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
|
||||||
mesh.position.z = Z.tear;
|
map: this.assets.tear(), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
mesh.scale.set(PROJECTILE.radius * 2, PROJECTILE.radius * 2, 1);
|
}));
|
||||||
|
const s = PROJECTILE.radius * 4;
|
||||||
|
mesh.scale.set(s, s, 1);
|
||||||
this.scene.add(mesh);
|
this.scene.add(mesh);
|
||||||
this.tearMeshes.set(t, mesh);
|
this.tearMeshes.set(t, mesh);
|
||||||
}
|
}
|
||||||
mesh.position.set(lerp(t.prevX, t.x, alpha), lerp(t.prevY, t.y, alpha), Z.tear);
|
mesh.position.set(lerp(t.prevX, t.x, alpha), TEAR_Y, lerp(t.prevY, t.y, alpha));
|
||||||
|
}
|
||||||
|
for (const [t, mesh] of this.tearMeshes) {
|
||||||
|
if (live.has(t)) continue;
|
||||||
|
this.scene.remove(mesh);
|
||||||
|
(mesh.material as THREE.Material).dispose();
|
||||||
|
this.tearMeshes.delete(t);
|
||||||
}
|
}
|
||||||
this.sweep(this.tearMeshes, live);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncSwing(game: Game): void {
|
private syncSwing(game: Game): void {
|
||||||
const s = game.meleeSwing;
|
const s = game.meleeSwing;
|
||||||
if (!s || !s.alive) { this.swingMesh.visible = false; return; }
|
if (!s || !s.alive) { this.swingMesh.visible = false; return; }
|
||||||
this.swingMesh.visible = true;
|
this.swingMesh.visible = true;
|
||||||
this.swingMesh.position.set(s.box.x + s.box.w / 2, s.box.y + s.box.h / 2, Z.swing);
|
this.swingMesh.position.set(s.box.x + s.box.w / 2, 2, s.box.y + s.box.h / 2);
|
||||||
this.swingMesh.scale.set(s.box.w, s.box.h, 1);
|
this.swingMesh.scale.set(s.box.w * 1.4, s.box.h * 1.4, 1);
|
||||||
(this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.45 * (s.life / 10);
|
(this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / MELEE.life);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Утилиты управления ресурсами ──────────────────────────
|
// ── Эффекты (частицы-биллборды) ───────────────────────────
|
||||||
|
|
||||||
/** Удаляет меши, чьих сущностей больше нет, освобождая их материалы. */
|
private spawnEffect(
|
||||||
private sweep<K>(map: Map<K, THREE.Mesh>, live: Set<K>): void {
|
tex: THREE.Texture, color: number, x: number, y: number, z: number,
|
||||||
for (const [key, mesh] of map) {
|
size: number, life: number, opts: { vy: number; grow: number },
|
||||||
if (live.has(key)) continue;
|
): void {
|
||||||
this.scene.remove(mesh);
|
const mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
|
||||||
(mesh.material as THREE.Material).dispose();
|
map: tex, color: new THREE.Color(color), transparent: true,
|
||||||
map.delete(key);
|
blending: THREE.AdditiveBlending, depthWrite: false,
|
||||||
|
}));
|
||||||
|
mesh.scale.set(size, size, 1);
|
||||||
|
mesh.position.set(x, y, z);
|
||||||
|
this.scene.add(mesh);
|
||||||
|
this.effects.push({ mesh, life, max: life, vy: opts.vy, grow: opts.grow });
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateEffects(): void {
|
||||||
|
for (let i = this.effects.length - 1; i >= 0; i--) {
|
||||||
|
const fx = this.effects[i];
|
||||||
|
fx.life--;
|
||||||
|
const mat = fx.mesh.material as THREE.MeshBasicMaterial;
|
||||||
|
if (fx.life <= 0) {
|
||||||
|
this.scene.remove(fx.mesh);
|
||||||
|
mat.dispose();
|
||||||
|
this.effects.splice(i, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
mat.opacity = fx.life / fx.max;
|
||||||
|
fx.mesh.position.y += fx.vy;
|
||||||
|
fx.mesh.scale.multiplyScalar(fx.grow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Хелперы ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Плоский (лежащий на полу) меш из общей геометрии. */
|
||||||
|
private flatMesh(mat: THREE.Material): THREE.Mesh {
|
||||||
|
const m = new THREE.Mesh(this.flatGeo, mat);
|
||||||
|
m.rotation.x = -Math.PI / 2; // положить плашмя, нормаль вверх
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeShadow(shadow: THREE.Mesh, x: number, z: number, footprint: number): void {
|
||||||
|
shadow.position.set(x, SHADOW_Y, z);
|
||||||
|
shadow.scale.set(footprint * 1.4, footprint * 0.9, 1);
|
||||||
|
}
|
||||||
|
|
||||||
private clearGroup(group: THREE.Group): void {
|
private clearGroup(group: THREE.Group): void {
|
||||||
// Материалы и геометрия тайлов общие (живут весь срок рендера),
|
for (const child of group.children) {
|
||||||
// поэтому здесь только убираем меши из сцены — без dispose.
|
// Общие материалы (пол/стены) не трогаем; персональные (двери) — освобождаем.
|
||||||
|
if ((child as THREE.Mesh).userData?.disposable) {
|
||||||
|
((child as THREE.Mesh).material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
group.clear();
|
group.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.clearGroup(this.roomGroup);
|
this.clearGroup(this.roomGroup);
|
||||||
this.sweep(this.enemyMeshes, new Set());
|
for (const v of this.enemyVisuals.values()) (v.sprite.material as THREE.Material).dispose();
|
||||||
this.sweep(this.tearMeshes, new Set());
|
for (const m of this.tearMeshes.values()) (m.material as THREE.Material).dispose();
|
||||||
(this.playerMesh.material as THREE.Material).dispose();
|
for (const fx of this.effects) (fx.mesh.material as THREE.Material).dispose();
|
||||||
(this.swingMesh.material as THREE.Material).dispose();
|
(this.swingMesh.material as THREE.Material).dispose();
|
||||||
this.unitPlane.dispose();
|
this.floorMat.dispose();
|
||||||
this.unitCircle.dispose();
|
this.wallMat.dispose();
|
||||||
for (const m of Object.values(this.tileMats)) m.dispose();
|
this.shadowMat.dispose();
|
||||||
|
this.playerMat.ranged.dispose();
|
||||||
|
this.playerMat.melee.dispose();
|
||||||
|
this.vGeo.dispose();
|
||||||
|
this.flatGeo.dispose();
|
||||||
|
this.assets.dispose();
|
||||||
this.renderer.dispose();
|
this.renderer.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* theme.ts — ВНЕШНИЙ ВИД мира (задел под кастомные ассеты).
|
* theme.ts — цвета-ТИНТЫ, которыми пользуется рендер напрямую.
|
||||||
*
|
*
|
||||||
* Сейчас «ассеты» — это просто цвета примитивов (квадраты/круги). Но рендер
|
* Основной внешний вид мира (пол, стены, двери, персонажи, снаряды) теперь живёт
|
||||||
* берёт их отсюда, а не из хардкода, поэтому вид легко подменить, не трогая
|
* в текстурах `render/assets.ts` (процедурные спрайты). Сюда вынесено лишь то, что
|
||||||
* логику: можно завести несколько тем или, в перспективе, расширить Theme
|
* рендер задаёт цветом материала, а не текстурой:
|
||||||
* полями со спрайтами/текстурами (см. комментарий ниже) и научить
|
* • bg — цвет фона (clear color) сцены;
|
||||||
* ThreeRenderer вешать их на материалы.
|
* • swing — тинт спрайта взмаха ближнего боя;
|
||||||
|
* • flash — тинт вспышек/искр (дуло, попадание).
|
||||||
|
*
|
||||||
|
* Хочешь полностью сменить стиль — меняй ассеты (см. `docs/HOWTO.md` и
|
||||||
|
* `docs/ASSET_BRIEF.md`); хочешь подкрутить фон/эффекты — здесь.
|
||||||
*/
|
*/
|
||||||
export interface Theme {
|
export interface Theme {
|
||||||
/** Цвета (0xRRGGBB) элементов мира. */
|
bg: number; // фон сцены (0xRRGGBB)
|
||||||
bg: number;
|
swing: number; // тинт взмаха ближнего боя
|
||||||
floorA: number;
|
flash: number; // тинт вспышек/искр
|
||||||
floorB: number;
|
|
||||||
wall: number;
|
|
||||||
door: number;
|
|
||||||
playerRanged: number;
|
|
||||||
playerMelee: number;
|
|
||||||
enemyNormal: number;
|
|
||||||
enemyFast: number;
|
|
||||||
enemyBoss: number;
|
|
||||||
tear: number;
|
|
||||||
swing: number;
|
|
||||||
flash: number; // цвет «вспышки» при попадании/неуязвимости
|
|
||||||
|
|
||||||
// ── Задел на будущее (пока не используется) ───────────────
|
|
||||||
// Чтобы перейти со сплошных цветов на картинки, добавь сюда, например:
|
|
||||||
// textures?: { floor?: string; wall?: string; player?: string; ... }
|
|
||||||
// (URL/путь к изображению), загрузи их через THREE.TextureLoader в
|
|
||||||
// ThreeRenderer и положи в material.map вместо/вместе с color.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Тема по умолчанию — текущая «тёмное подземелье». */
|
/** Тема по умолчанию — «тёмное подземелье». */
|
||||||
export const DEFAULT_THEME: Theme = {
|
export const DEFAULT_THEME: Theme = {
|
||||||
bg: 0x0a0a0f,
|
bg: 0x1c1c28,
|
||||||
floorA: 0x2e2e24,
|
|
||||||
floorB: 0x353528,
|
|
||||||
wall: 0x242436,
|
|
||||||
door: 0x3a2e14,
|
|
||||||
playerRanged: 0x2a6a9a,
|
|
||||||
playerMelee: 0x9a3a2a,
|
|
||||||
enemyNormal: 0x5a4a2e,
|
|
||||||
enemyFast: 0x992222,
|
|
||||||
enemyBoss: 0x5a0a0a,
|
|
||||||
tear: 0x6699cc,
|
|
||||||
swing: 0xcc8844,
|
swing: 0xcc8844,
|
||||||
flash: 0xdddddd,
|
flash: 0xdddddd,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,4 +41,21 @@ describe('Rng', () => {
|
|||||||
const shuffled = r.shuffle([...arr]);
|
const shuffled = r.shuffle([...arr]);
|
||||||
expect([...shuffled].sort()).toEqual(arr);
|
expect([...shuffled].sort()).toEqual(arr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('авто-seed не вызывает Math.random()', () => {
|
||||||
|
const math = Math as Math & { random: () => number };
|
||||||
|
const original = math.random;
|
||||||
|
math.random = () => { throw new Error('Math.random вызван'); };
|
||||||
|
try {
|
||||||
|
const r = new Rng();
|
||||||
|
expect(r.next()).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(r.next()).toBeLessThan(1);
|
||||||
|
} finally {
|
||||||
|
math.random = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pick явно ругается на пустой массив', () => {
|
||||||
|
expect(() => new Rng(1).pick([])).toThrow('пустой массив');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import { Room } from '../src/core/world/Room';
|
|||||||
import { Rng } from '../src/core/rng';
|
import { Rng } from '../src/core/rng';
|
||||||
import { OX, OY, TILE, DOOR, SPAWN } from '../src/config';
|
import { OX, OY, TILE, DOOR, SPAWN } from '../src/config';
|
||||||
import { dist } from '../src/core/util';
|
import { dist } from '../src/core/util';
|
||||||
|
import { DEFAULT_RULES, type LevelRules } from '../src/core/rules';
|
||||||
|
|
||||||
|
const denseRules: LevelRules = {
|
||||||
|
...DEFAULT_RULES,
|
||||||
|
id: 'dense-test',
|
||||||
|
name: 'Тестовая тесная комната',
|
||||||
|
description: 'Много врагов, чтобы проверить отказ от плохих позиций спавна.',
|
||||||
|
map: { ...DEFAULT_RULES.map },
|
||||||
|
player: { ...DEFAULT_RULES.player },
|
||||||
|
enemies: { ...DEFAULT_RULES.enemies, densityMul: 40 },
|
||||||
|
};
|
||||||
|
|
||||||
describe('spawner', () => {
|
describe('spawner', () => {
|
||||||
it('в обычной комнате врагов в ожидаемом диапазоне', () => {
|
it('в обычной комнате врагов в ожидаемом диапазоне', () => {
|
||||||
@@ -40,4 +51,24 @@ describe('spawner', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('РЕГРЕССИЯ: при переполнении не ставит врага в последнюю плохую точку', () => {
|
||||||
|
const room = new Room(1, 0, 'normal');
|
||||||
|
const playerX = OX + 7 * TILE;
|
||||||
|
const playerY = OY + 5 * TILE;
|
||||||
|
const door = DOOR.left;
|
||||||
|
const doorX = OX + door.cx * TILE + TILE / 2;
|
||||||
|
const doorY = OY + door.cy * TILE + TILE / 2;
|
||||||
|
const enemies = spawnEnemies(room, 'left', playerX, playerY, new Rng(12), denseRules);
|
||||||
|
|
||||||
|
expect(enemies.length).toBeGreaterThan(0);
|
||||||
|
for (let i = 0; i < enemies.length; i++) {
|
||||||
|
const e = enemies[i];
|
||||||
|
expect(dist(e.x, e.y, doorX, doorY)).toBeGreaterThanOrEqual(SPAWN.minDistFromDoor);
|
||||||
|
expect(dist(e.x, e.y, playerX, playerY)).toBeGreaterThanOrEqual(SPAWN.minDistFromPlayer);
|
||||||
|
for (let j = i + 1; j < enemies.length; j++) {
|
||||||
|
expect(dist(e.x, e.y, enemies[j].x, enemies[j].y)).toBeGreaterThanOrEqual(SPAWN.minDistBetween);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||