Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a72172df72 | ||
|
|
5d676b7d95 | ||
|
|
3cdd3ac738 | ||
|
|
db545ce80b | ||
|
|
e55666f51c | ||
|
|
01c1f71159 | ||
|
|
d2d1d2381d | ||
|
|
b7362a2ad8 | ||
|
|
58bf96e9d9 | ||
|
|
7dabfd4fff | ||
|
|
35cb8de33a | ||
|
|
e2cb1ff855 | ||
|
|
3bb46e2f6b | ||
|
|
f4316431e0 | ||
|
|
5a8cf0e500 | ||
|
|
0684368ac7 |
@@ -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/`
|
||||
|
||||
## Тип изменений
|
||||
|
||||
- [ ] Исправление бага
|
||||
- [ ] Новая функциональность
|
||||
- [ ] Рефакторинг
|
||||
- [ ] Документация
|
||||
- [ ] Другое
|
||||
|
||||
## Дополнительно
|
||||
|
||||
<!-- Любая дополнительная информация для ревьюера -->
|
||||
@@ -1,4 +1,4 @@
|
||||
node_modules/
|
||||
dist/*.map
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@@ -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, пока этого не попросили (рендер для этого
|
||||
готов — он изолирован, — но это отдельная большая задача).
|
||||
@@ -0,0 +1,105 @@
|
||||
# CLAUDE.md — как работать с этим проектом
|
||||
|
||||
Инструкции для ИИ-агентов **и** разработчика. Прочитай целиком перед правками.
|
||||
Цель проекта — держать **рабочую, расширяемую базу** рогалика. Не ломать то, что
|
||||
работает; добавлять — по правилам ниже.
|
||||
|
||||
## Что это
|
||||
|
||||
Top-down рогалик (в духе Binding of Isaac). Логика — чистый TypeScript
|
||||
(`src/core/`), рендер — three.js в псевдо-3D (наклонная `PerspectiveCamera`, `src/render/`), сборка/тесты — Bun.
|
||||
Обзор — [`README.md`](./README.md), детали — [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md),
|
||||
рецепты доработки — [`docs/HOWTO.md`](./docs/HOWTO.md).
|
||||
|
||||
## 🔑 Золотые правила (нарушать = ломать архитектуру)
|
||||
|
||||
1. **Ядро без рендера и DOM.** В `src/core/**` НЕЛЬЗЯ импортировать `three`,
|
||||
обращаться к `window`/`document`/`canvas`. Логика общается с миром только через
|
||||
`InputState` (вход) и публичные поля `Game` (для чтения рендером).
|
||||
2. **Рендер ничего не меняет в игре.** `src/render/**` только ЧИТАЕТ `Game` и
|
||||
рисует. Любая мутация состояния из рендера — баг.
|
||||
3. **Случайность только через `Rng`.** Никаких `Math.random()` в `core/`. Это
|
||||
сохраняет детерминизм (тесты, отладка по seed).
|
||||
4. **Время — в «шагах» (1/60 c), а не в кадрах.** Скорости — «пиксели/шаг»,
|
||||
перезарядки — «шаги». Не двигай ничего в коде рендера или прямо в rAF.
|
||||
5. **Числа — не в коде.** Константы движка (размеры, геометрия дверей, базовый
|
||||
баланс) — в `src/config.ts`; параметры конкретного забега (размер карты, сила
|
||||
врагов, HP игрока, seed) — в правилах уровня `src/core/rules.ts`. Не раскидывай
|
||||
«магические числа» по логике.
|
||||
6. **Освобождай ресурсы three.js.** Создаёшь геометрию/материал на сущность —
|
||||
обеспечь `dispose()` при её удалении (см. `sync*`/`sweep` в `ThreeRenderer`).
|
||||
7. **Комментарии и текст для игрока — по-русски**, как в существующем коде.
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
bun run dev # дев-сервер + watch → http://localhost:3000
|
||||
bun run build # прод-сборка в dist/
|
||||
bun run typecheck # tsc --noEmit (строгий) — НЕ ловится при bun build!
|
||||
bun test # юнит-тесты ядра
|
||||
bun run check # typecheck + test
|
||||
```
|
||||
|
||||
> ⚠️ `bun build` **не проверяет типы**. Поэтому `bun run typecheck` обязателен —
|
||||
> именно отсутствие тайп-чека когда-то скрыло рантайм-регрессию.
|
||||
|
||||
## Definition of Done (для любой правки)
|
||||
|
||||
1. `bun run check` зелёный (типы + тесты).
|
||||
2. Если менял логику — **добавил/обновил тест** в `tests/`.
|
||||
3. Если менял геймплей/рендер — **проверил в браузере** (`bun run dev`, открыть
|
||||
страницу, увидеть, что играется, в консоли нет ошибок). Юнит-тесты не видят
|
||||
рендер — визуальную проверку не пропускать.
|
||||
4. Обновил доки, если поменялось поведение или структура.
|
||||
|
||||
## Где что лежит (карта для навигации)
|
||||
|
||||
| Хочешь поменять… | Иди в… |
|
||||
|---|---|
|
||||
| константы движка (размер тайла/комнаты, геометрия дверей, базовый баланс) | `src/config.ts` |
|
||||
| правила уровня / пресеты в меню (размер карты, сила врагов, HP, seed) | `src/core/rules.ts` |
|
||||
| поведение за один шаг (движение, атака, ИИ, переходы) | `src/core/Game.ts` |
|
||||
| данные сущности | `src/core/entities/*` |
|
||||
| генерацию карты | `src/core/world/RoomMap.ts` |
|
||||
| форму комнаты/тайлы | `src/core/world/tiles.ts`, `Room.ts` |
|
||||
| коллизии | `src/core/systems/collision.ts` |
|
||||
| расстановку врагов | `src/core/systems/spawner.ts` |
|
||||
| как рисуется мир (псевдо-3D) | `src/render/ThreeRenderer.ts` |
|
||||
| спрайты/текстуры (графика) | `src/render/assets.ts` (бриф на художку — `docs/ASSET_BRIEF.md`) |
|
||||
| цвета/тинты/фон | `src/render/theme.ts` |
|
||||
| HUD/миникарту | `src/render/HudOverlay.ts` |
|
||||
| стартовое меню | `src/ui/StartMenu.ts` |
|
||||
| раскладку клавиш | `src/input/KeyboardController.ts` |
|
||||
| тайминг/цикл, поток меню↔игра | `src/engine/GameLoop.ts`, `src/main.ts` |
|
||||
|
||||
Пошаговые рецепты («добавить врага», «новое оружие», «тип комнаты», «сменить
|
||||
рендер») — в [`docs/HOWTO.md`](./docs/HOWTO.md).
|
||||
|
||||
## Грабли, на которые уже наступали (не повторять)
|
||||
|
||||
- **Пустая карта.** `new RoomMap(rng)` ДОЛЖЕН генерировать карту в конструкторе.
|
||||
Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте.
|
||||
- **`OPP` направлений.** Противоположное к `up` — это `down`, к `left` — `right`.
|
||||
Любая другая раскладка ломает встречные двери и связность карты.
|
||||
- **Пол «выворачивает» перспективу.** Пол в псевдо-3D надо класть ПЛАШМЯ
|
||||
(`flatMesh`, поворот −90° вокруг X). Без поворота он встаёт вертикально и вид
|
||||
ломается. Спрайты/стены — `DoubleSide` (камера переворачивает Y, иначе грани отсекаются).
|
||||
- **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`),
|
||||
иначе они показываются в размер HiDPI-буфера.
|
||||
- **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) —
|
||||
она должна стать `cleared` сразу при входе, иначе двери не появятся.
|
||||
|
||||
## Стиль кода
|
||||
|
||||
- TypeScript strict, без `any` (кроме узких мест вроде `window as …` в `main.ts`).
|
||||
- Маленькие чистые функции для логики; классы — для сущностей/состояния.
|
||||
- Имена и комментарии осмысленные, по-русски. Комментарий объясняет «почему», а не «что».
|
||||
- Перед коммитом — `bun run check`.
|
||||
|
||||
## Чего НЕ делать без явной просьбы
|
||||
|
||||
- Не добавлять тяжёлые зависимости (физдвижки, фреймворки). База намеренно лёгкая.
|
||||
- Не переписывать архитектуру «ядро ↔ рендер».
|
||||
- Не коммитить `dist/` и `node_modules/` (см. `.gitignore`).
|
||||
- Не превращать игру в полноценное 3D, пока этого не попросили (рендер для этого
|
||||
готов — он изолирован, — но это отдельная большая задача).
|
||||
@@ -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,94 +1,120 @@
|
||||
# Dungeon Crawl — Ranged / Melee
|
||||
# Биндим Фигняшку — рогалик в духе The Binding of Isaac
|
||||
|
||||
A dark fantasy dungeon crawler inspired by *The Binding of Isaac*.
|
||||
Built with TypeScript and Canvas 2D, bundled with **Bun**.
|
||||
Top-down рогалик: процедурный данжен из комнат, два режима боя (дальний/ближний),
|
||||
враги, босс. Логика на чистом TypeScript, рендер — на **three.js** в **псевдо-3D**:
|
||||
наклонная камера, пол лежит плашмя, персонажи — вертикальные спрайты-биллборды
|
||||
(как в Isaac). Сборка — **Bun**.
|
||||
|
||||
## Quick Start
|
||||
<p align="center">
|
||||
<img src="./docs/assets/binding_fignyashka.png" alt="Биндим Фигняшку" width="720" />
|
||||
</p>
|
||||
|
||||
Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
|
||||
забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
|
||||
Двери видны всегда (закрыты в бою, открыты после зачистки). Графика — PNG-ассеты в
|
||||
`src/assets/` (грузятся в `render/assets.ts`; при отсутствии файла — процедурный
|
||||
фолбэк). Бриф на художку — `docs/ASSET_BRIEF.md`.
|
||||
|
||||
> Это рабочая **база для развития**, а не готовая игра. Архитектура специально
|
||||
> сделана так, чтобы её было легко расширять — и человеку, и ИИ-агентам.
|
||||
> Перед доработкой прочитай [`CLAUDE.md`](./CLAUDE.md) и [`docs/HOWTO.md`](./docs/HOWTO.md).
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
bun install # install deps (none currently required)
|
||||
bun start # production build → dist/
|
||||
open dist/index.html
|
||||
bun install # поставить зависимости (three, typescript)
|
||||
bun run dev # дев-сервер с авто-пересборкой → http://localhost:3000
|
||||
```
|
||||
|
||||
Or for development with live reload:
|
||||
Продакшн-сборка:
|
||||
|
||||
```bash
|
||||
bun run dev # starts dev server at http://localhost:3000 + watch mode
|
||||
bun run build # минифицированный бандл → dist/ (открой dist/index.html)
|
||||
```
|
||||
|
||||
## Controls
|
||||
Проверки (запускай перед любым коммитом):
|
||||
|
||||
| Key | Action |
|
||||
```bash
|
||||
bun run typecheck # tsc --noEmit, строгий режим
|
||||
bun test # юнит-тесты ядра
|
||||
bun run check # и то, и другое разом
|
||||
```
|
||||
|
||||
## Управление
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---|---|
|
||||
| WASD | Move |
|
||||
| Arrow keys | Attack in direction |
|
||||
| Space | Attack in facing direction |
|
||||
| Tab / Q | Switch weapon (Ranged ↔ Melee) |
|
||||
| R | Restart (on Game Over / Victory) |
|
||||
| WASD | движение |
|
||||
| Стрелки | прицельная стрельба/удар в направлении |
|
||||
| Пробел | атака по ходу движения |
|
||||
| Tab / Q | сменить оружие (Дальний ↔ Ближний) |
|
||||
| R | заново (на экране Game Over / Victory) |
|
||||
| Esc | вернуться в меню выбора уровня |
|
||||
|
||||
## Weapons
|
||||
Клавиши привязаны к **физическим** кнопкам (`event.code`), поэтому WASD/Q/R работают
|
||||
в любой раскладке (в т.ч. русской), независимо от языка ввода.
|
||||
|
||||
| Mode | Weapon | Speed | Damage | Effect |
|
||||
Чтобы перейти в соседнюю комнату — зачисти текущую (двери откроются) и встань на
|
||||
дверь, нажимая в её сторону.
|
||||
|
||||
## Режимы боя
|
||||
|
||||
| Режим | Оружие | Скорость | Урон | Особенность |
|
||||
|---|---|---|---|---|
|
||||
| Ranged | Pistol | Fast (10cd) | 1 per shot | Ranged projectiles |
|
||||
| Melee | Knife | Slow (22cd) | 2 + knockback | Wide swing arc |
|
||||
| Дальний | пистолет | быстро (cd 10) | 1 за выстрел | снаряды летят по прямой |
|
||||
| Ближний | нож | медленно (cd 22) | 2 + отбрасывание | широкий взмах |
|
||||
|
||||
## Room Types
|
||||
## Типы комнат
|
||||
|
||||
| Type | Description |
|
||||
| Тип | Описание |
|
||||
|---|---|
|
||||
| Spawn | Starting room, no enemies |
|
||||
| Normal | 2–4 enemies |
|
||||
| Treasure | No enemies, loot room |
|
||||
| Boss | 1 boss enemy, clearing wins the game |
|
||||
| spawn | старт, врагов нет |
|
||||
| normal | 2–4 врага |
|
||||
| treasure | без врагов (комната-награда), сразу открыта |
|
||||
| boss | 1 босс; его зачистка = победа |
|
||||
|
||||
## Project Structure
|
||||
## Стек и устройство
|
||||
|
||||
- **Bun** — рантайм, бандлер и тест-раннер.
|
||||
- **three.js** — WebGL-рендер мира в псевдо-3D (наклонная `PerspectiveCamera`, биллборд-спрайты).
|
||||
- **TypeScript (strict)** — весь код.
|
||||
- Архитектура: **логика игры полностью отделена от рендера**. Ядро (`src/core/`)
|
||||
не знает ни про DOM, ни про three.js, поэтому его легко тестировать и при
|
||||
желании можно подменить рендер, не трогая игру.
|
||||
|
||||
Подробности — в [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts Entry point
|
||||
├── constants.ts Grid, canvas, tile/door constants
|
||||
├── types.ts Shared TypeScript types
|
||||
├── math.ts Utility: shuffle, rand, dist, overlap
|
||||
├── input.ts Keyboard state (global KEYS map)
|
||||
├── doors.ts Door geometry helpers
|
||||
├── room/
|
||||
│ ├── Room.ts Room class (tiles, enemies, tears)
|
||||
│ ├── RoomMap.ts Map generation (connected 7×7 grid)
|
||||
│ └── tiles.ts Tile builders (wall/floor/door placement)
|
||||
├── entities/
|
||||
│ ├── Player.ts Player stat block
|
||||
│ ├── Enemy.ts Enemy stat block + enemy type logic
|
||||
│ ├── Tear.ts Ranged projectile
|
||||
│ └── MeleeSwing.ts Melee hitbox
|
||||
├── game/
|
||||
│ ├── Game.ts Main orchestrator (loop, tick, render)
|
||||
│ ├── collision.ts Wall collision (isBlocked, collidesWall)
|
||||
│ ├── transitions.ts Room transition detection
|
||||
│ └── spawner.ts Enemy placement logic
|
||||
└── render/
|
||||
├── roomRenderer.ts Tile rendering
|
||||
├── entityRenderer.ts Player, enemy, tear, melee drawing
|
||||
├── hudRenderer.ts HP bar, mode indicator, HUD
|
||||
└── minimapRenderer.ts Minimap drawing
|
||||
├── config.ts ⭐ КОНСТАНТЫ движка: размеры, геометрия дверей, базовый баланс
|
||||
├── main.ts точка входа: меню → игра, «склейка» логики/рендера/ввода
|
||||
├── core/ ── ИГРОВАЯ ЛОГИКА (без DOM и three.js) ──
|
||||
│ ├── Game.ts «мозг»: состояние + один шаг симуляции step()
|
||||
│ ├── rules.ts ⭐ ПРАВИЛА УРОВНЯ (пресеты для меню: размер, враги, HP, seed)
|
||||
│ ├── types.ts общие типы (Dir, RoomType, Box, …)
|
||||
│ ├── rng.ts ГПСЧ с seed (детерминизм для тестов/отладки)
|
||||
│ ├── util.ts мат-утилиты (dist, overlap, clamp, lerp)
|
||||
│ ├── entities/ Player, Enemy, Projectile, MeleeSwing
|
||||
│ ├── world/ Room, RoomMap (генерация), tiles
|
||||
│ └── systems/ collision, spawner (чистые функции)
|
||||
├── input/ ── ВВОД ──
|
||||
│ ├── InputState.ts абстрактные «намерения» (не сырые клавиши)
|
||||
│ └── KeyboardController.ts клавиатура → InputState
|
||||
├── render/ ── РЕНДЕР (читает состояние, рисует) ──
|
||||
│ ├── Renderer.ts интерфейс рендера
|
||||
│ ├── ThreeRenderer.ts мир на three.js (псевдо-3D: наклон + биллборды)
|
||||
│ ├── assets.ts ⭐ АССЕТЫ (процедурные спрайты/текстуры → текстуры three.js)
|
||||
│ ├── theme.ts цвета/тинты мира (фон, эффекты)
|
||||
│ └── HudOverlay.ts HUD и миникарта на 2D-канвасе поверх
|
||||
├── ui/
|
||||
│ └── StartMenu.ts стартовое меню (DOM) с выбором уровня
|
||||
└── engine/
|
||||
└── GameLoop.ts игровой цикл с фиксированным шагом (60 Гц)
|
||||
|
||||
tests/ юнит-тесты ядра (bun test)
|
||||
docs/ARCHITECTURE.md архитектура и потоки данных
|
||||
CLAUDE.md правила для ИИ-агентов и разработчика
|
||||
docs/HOWTO.md рецепты: как добавить врага/оружие/комнату
|
||||
```
|
||||
|
||||
## Build System
|
||||
|
||||
- **Bun** — runtime + bundler
|
||||
- `bun build` — bundles `src/main.ts` → `dist/main.js`
|
||||
- `build.ts` — production build with minification + HTML copy
|
||||
- No external libraries required (pure Canvas 2D)
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Game** owns the game loop (`requestAnimationFrame`), the world
|
||||
(`RoomMap`, `Player`), and dispatches to render functions.
|
||||
- **Tick** processes input → movement → attack → enemy AI → room
|
||||
clear check → transitions → win condition.
|
||||
- **Transitions** check player tile position + keypress against door
|
||||
geometry; call `Game.enterRoom()` which resets room state.
|
||||
- **Collision** allows bounding-box overlap at door openings (row/col
|
||||
outside normal bounds).
|
||||
- **Rendering** is split into 4 pure functions that receive `ctx` + data.
|
||||
|
||||
@@ -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` из свежего клона).
|
||||
@@ -1,35 +1,39 @@
|
||||
import { build } from "bun";
|
||||
/**
|
||||
* Продакшн-сборка: минифицированный бандл src/main.ts → dist/main.js
|
||||
* плюс копия index.html. Открывай dist/index.html.
|
||||
*/
|
||||
import { build } from 'bun';
|
||||
import { cp, mkdir } from 'node:fs/promises';
|
||||
|
||||
const result = await build({
|
||||
entrypoints: ["./src/main.ts"],
|
||||
outdir: "./dist",
|
||||
target: "browser",
|
||||
entrypoints: ['./src/main.ts'],
|
||||
outdir: './dist',
|
||||
target: 'browser',
|
||||
minify: true,
|
||||
sourcemap: "external",
|
||||
sourcemap: 'external',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error("Build failed", result.logs);
|
||||
console.error('Сборка упала:');
|
||||
for (const log of result.logs) console.error(log);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Copy HTML template with correct script reference
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dungeon Crawl — Ranged / Melee</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
|
||||
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="880" height="660"></canvas>
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
await Bun.write('./dist/index.html', await Bun.file('./index.html').text());
|
||||
|
||||
Bun.write("./dist/index.html", html);
|
||||
console.log("Build complete → dist/");
|
||||
// Копируем картинки в dist/<asset-pack> (если папка есть).
|
||||
try {
|
||||
await mkdir('./dist/assets', { recursive: true });
|
||||
await cp('./src/assets', './dist/assets', { recursive: true });
|
||||
} catch {
|
||||
// src/assets ещё нет — не страшно, рендер откатится на процедурную графику.
|
||||
}
|
||||
|
||||
try {
|
||||
await mkdir('./dist/assets-binding-2', { recursive: true });
|
||||
await cp('./src/assets-binding-2', './dist/assets-binding-2', { recursive: true });
|
||||
} catch {
|
||||
// Второй пак ассетов опционален.
|
||||
}
|
||||
|
||||
console.log('Сборка готова → dist/ (открой dist/index.html)');
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "dungeon-crawl",
|
||||
"dependencies": {
|
||||
"three": "^0.184.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/three": "^0.184.1",
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
|
||||
|
||||
"@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="],
|
||||
|
||||
"@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="],
|
||||
|
||||
"@types/three": ["@types/three@0.184.1", "", { "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", "fflate": "~0.8.2", "meshoptimizer": "~1.1.1" } }, "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA=="],
|
||||
|
||||
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
|
||||
|
||||
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
||||
|
||||
"meshoptimizer": ["meshoptimizer@1.1.1", "", {}, "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g=="],
|
||||
|
||||
"three": ["three@0.184.0", "", {}, "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,43 @@
|
||||
import { spawn } from "bun";
|
||||
/**
|
||||
* Дев-сервер: бандлит src/main.ts → dist/main.js в watch-режиме и раздаёт
|
||||
* dist на http://localhost:3000. index.html копируется из корня (единый
|
||||
* источник правды) — правь его там, не в dist.
|
||||
*/
|
||||
import { spawn } from 'bun';
|
||||
|
||||
// Ensure dist/ has the HTML shell
|
||||
await Bun.write(
|
||||
"./dist/index.html",
|
||||
`<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dungeon Crawl — Ranged / Melee</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
|
||||
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="880" height="660"></canvas>
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>`
|
||||
const builder = spawn(
|
||||
// --sourcemap=linked: в DevTools брейкпоинты/стек указывают на твой src/*.ts,
|
||||
// а не на собранный main.js. .map отдаётся из dist тем же сервером.
|
||||
['bun', 'build', 'src/main.ts', '--outdir', 'dist', '--target', 'browser', '--sourcemap=linked', '--watch'],
|
||||
{ stdout: 'inherit', stderr: 'inherit' },
|
||||
);
|
||||
|
||||
// Start build watcher
|
||||
const builder = spawn(["bun", "build", "src/main.ts", "--outdir", "dist", "--target", "browser", "--watch"], {
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
|
||||
// Start dev server
|
||||
const port = 3000;
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
let path = url.pathname === "/" ? "/index.html" : url.pathname;
|
||||
const file = Bun.file("./dist" + path);
|
||||
return new Response(file);
|
||||
// index.html отдаём прямо из корня — правки CSS/разметки сразу живые;
|
||||
// бандл main.js и карты — из dist (их пишет watch-сборщик).
|
||||
if (url.pathname === '/' || url.pathname === '/index.html') {
|
||||
return new Response(Bun.file('./index.html'));
|
||||
}
|
||||
// Картинки отдаём прямо из src/<asset-pack>/ — положил PNG → сразу подхватился.
|
||||
if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/assets-binding-2/')) {
|
||||
const asset = Bun.file('./src' + url.pathname);
|
||||
if (await asset.exists()) return new Response(asset);
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
const file = Bun.file('./dist' + url.pathname);
|
||||
if (await file.exists()) return new Response(file);
|
||||
return new Response('Not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`\n Dev server: http://localhost:${port}`);
|
||||
console.log(` Watching: src/\n`);
|
||||
console.log(`\n Dev server: http://localhost:${server.port}`);
|
||||
console.log(' Watching: src/\n');
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGINT", () => {
|
||||
process.on('SIGINT', () => {
|
||||
builder.kill();
|
||||
server.stop();
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dungeon Crawl — Ranged / Melee</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
|
||||
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="880" height="660"></canvas>
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,131 +1,209 @@
|
||||
# Architecture
|
||||
# Архитектура
|
||||
|
||||
## Overview
|
||||
|
||||
The game is a single-page Canvas 2D application. The source is written
|
||||
in TypeScript and split into ~19 modules under `src/`, bundled by Bun
|
||||
into a single `dist/main.js`. The HTML shell in `dist/index.html` loads
|
||||
the bundle.
|
||||
|
||||
## Module Dependency Graph
|
||||
## Главный принцип: логика отдельно от рендера
|
||||
|
||||
```
|
||||
main.ts
|
||||
├── input.ts (global KEYS, setupInput)
|
||||
└── Game.ts
|
||||
├── constants.ts (all shared numeric constants)
|
||||
├── types.ts (Dir, RoomType, CombatMode, Box, Doors)
|
||||
├── math.ts (shuffle, rand, ri, dist, overlap)
|
||||
├── RoomMap.ts → Room.ts, tiles.ts, doors.ts
|
||||
├── Player.ts
|
||||
├── Enemy.ts
|
||||
├── Tear.ts
|
||||
├── MeleeSwing.ts
|
||||
├── collision.ts → Room.ts, tiles.ts
|
||||
├── transitions.ts → Game.ts (type only), input.ts
|
||||
├── spawner.ts → Room.ts, Enemy.ts
|
||||
└── render/*
|
||||
→ roomRenderer.ts, entityRenderer.ts,
|
||||
hudRenderer.ts, minimapRenderer.ts
|
||||
┌──────────────────────────────────────────┐
|
||||
│ GameLoop (engine/) │
|
||||
│ фиксированный шаг 60 Гц + интерполяция │
|
||||
└───────┬───────────────┬──────────────┬─────┘
|
||||
│ poll() │ step(input) │ render(game, alpha)
|
||||
▼ ▼ ▼
|
||||
KeyboardController Game (core/) Renderer (render/)
|
||||
клавиши → InputState состояние + ЧИТАЕТ состояние,
|
||||
логика игры рисует three.js + HUD
|
||||
```
|
||||
|
||||
## Game Loop
|
||||
Три части не знают лишнего друг о друге:
|
||||
|
||||
Every frame (`requestAnimationFrame`):
|
||||
- **`core/`** — игровая логика. Не импортирует ни `three`, ни DOM (`window`,
|
||||
`document`, `canvas`). Получает на вход абстрактный `InputState`, меняет своё
|
||||
состояние. Поэтому ядро **тестируется без браузера** (`bun test`) и не зависит
|
||||
от того, чем мы рисуем.
|
||||
- **`render/`** — рендер. Только **читает** `Game` и рисует. Менять состояние
|
||||
игры рендеру запрещено.
|
||||
- **`input/`** — превращает устройство ввода (клавиатуру) в абстрактные
|
||||
«намерения» (`InputState`). Захочешь геймпад — добавишь ещё один контроллер.
|
||||
|
||||
1. **Timers** — decrement `invTimer`, `atkCD`, `transCD`
|
||||
2. **Movement** — read WASD, apply velocity, resolve wall collisions
|
||||
3. **Attack** — read arrow keys / Space, fire tear or create melee swing
|
||||
4. **Melee Update** — decrement swing life, apply damage + knockback
|
||||
5. **Tear Update** — move projectiles, check wall/enemy collisions
|
||||
6. **Enemy AI** — chase player, contact damage with cooldown
|
||||
7. **Room Clear** — if all enemies dead → `room.cleared = true`, rebuild tiles with doors
|
||||
8. **Transition Check** — if player on door tile + key pressed → load adjacent room
|
||||
9. **Win Check** — if boss room cleared → `won = true`
|
||||
10. **Render** — draw room tiles → entities → HUD → minimap → overlays
|
||||
`main.ts` — единственное место, где всё это соединяется.
|
||||
|
||||
## Transition System
|
||||
> **Почему так строго.** Этот проект уже один раз сломался, когда логику,
|
||||
> рендер и состояние перемешали и расползлись по модулям. Граница «логика ↔
|
||||
> рендер» — то, что не даёт повторить ту историю. Не протаскивай `three` в
|
||||
> `core/` и не лезь в игровое состояние из рендера.
|
||||
|
||||
Room transitions are the most complex subsystem. Key design:
|
||||
## Игровой цикл с фиксированным шагом (engine/GameLoop.ts)
|
||||
|
||||
- **Door geometry**: `DOOR` constant defines 3-tile-wide openings at
|
||||
each cardinal edge (top: cols 6-8, row 0; bottom: cols 6-8, row 10;
|
||||
left: rows 4-6, col 0; right: rows 4-6, col 14).
|
||||
- **Detection**: `checkTransition()` computes the player's tile position
|
||||
(`col, row`). If they're on a door tile AND pressing the matching
|
||||
movement key, the transition fires.
|
||||
- **Cooldown**: `transCD = 15` prevents re-entry within 15 frames.
|
||||
- **Movement lock**: Transition only works in cleared rooms
|
||||
(`room.cleared === true`).
|
||||
- **Collision bypass**: `isBlocked()` returns `false` for out-of-bounds
|
||||
tiles at door openings, letting the player's bounding box extend
|
||||
beyond the room boundary.
|
||||
Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
|
||||
зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
|
||||
|
||||
## Collision System
|
||||
1. Раз в кадр опрашиваем источник ввода (`controller.poll()`). Цикл знает только
|
||||
маленький интерфейс `poll(): InputState`, а не конкретную клавиатуру.
|
||||
2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`.
|
||||
3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными
|
||||
порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
|
||||
4. Рисуем с коэффициентом интерполяции `alpha` (доля до следующего шага), чтобы
|
||||
движение было плавным и на 144 Гц.
|
||||
|
||||
- `isBlocked(room, col, row)` — per-tile check. Returns `true` for
|
||||
wall tiles and out-of-bounds positions, except at door openings.
|
||||
- `collidesWall(box, room, ox, oy)` — iterates all tiles covered by
|
||||
the entity's bounding box. If any tile is blocked, returns `true`.
|
||||
- Movement resolves axis-independently: apply X, revert if collision;
|
||||
apply Y, revert if collision.
|
||||
Единица времени везде — **шаг** (= 1/60 c). Скорости заданы «пикселей за шаг»,
|
||||
перезарядки — «в шагах».
|
||||
|
||||
## Room Generation
|
||||
## Шаг симуляции (core/Game.ts → step)
|
||||
|
||||
`RoomMap.generate()` uses a random walk:
|
||||
Каждый шаг по порядку:
|
||||
|
||||
1. Start at (0,0) with a spawn room.
|
||||
2. Maintain a frontier list of rooms that have room to expand.
|
||||
3. Each step, pick a random frontier room, shuffle directions, and
|
||||
attempt to add a new room in an unoccupied adjacent cell within
|
||||
the 7×7 grid.
|
||||
4. Room types are assigned probabilistically (boss at ~20%, treasure
|
||||
at ~12%, rest normal).
|
||||
5. If no boss room was generated, one normal room is promoted.
|
||||
1. **Снимок prev-позиций** — `prevX/prevY` всех подвижных сущностей (для интерполяции).
|
||||
2. **Таймеры** — `invTimer`, `atkCD`, `transCD`.
|
||||
3. **Движение** игрока (WASD), коллизии со стенами по осям раздельно (скольжение).
|
||||
4. **Атака** — стрелки/пробел: выстрел (`Projectile`) или взмах (`MeleeSwing`).
|
||||
5. **Ближний бой** — урон+отбрасывание по врагам в хитбоксе взмаха.
|
||||
6. **Снаряды** — полёт, попадание в стену/врага.
|
||||
7. **ИИ врагов** — преследование игрока, контактный урон с перезарядкой.
|
||||
8. **Зачистка** — если врагов было >0 и все мертвы → `cleared=true`, открыть двери.
|
||||
9. **Переход** — стоя на двери и нажимая в её сторону → соседняя комната.
|
||||
10. **Победа** — если комната-босс зачищена → `won=true`.
|
||||
|
||||
## Rendering
|
||||
Смерть (`hp<=0`) ставит `gameOver=true`; пока `gameOver`/`won` — `step()` ничего не делает.
|
||||
|
||||
Rendering is split into 4 stateless functions, each taking
|
||||
`CanvasRenderingContext2D` as the first argument:
|
||||
## Ввод (input/)
|
||||
|
||||
- **roomRenderer** — tile loop with wall/door/floor styles
|
||||
- **entityRenderer** — enemies (3 types), player (body + weapon),
|
||||
tears, melee swing arc
|
||||
- **hudRenderer** — HP bar, mode indicator, enemy count, room label
|
||||
- **minimapRenderer** — 7×7 grid with visited/current room highlights
|
||||
`InputState` — снимок намерений: оси движения, вектор прицела, флаги
|
||||
удержания и однократные «edge»-действия. Делится на:
|
||||
|
||||
## Weapon Visuals
|
||||
- **удерживаемые** (`moveX/Y`, `aimVec`, `attackHeld`) — читаются каждый шаг;
|
||||
`aimVec` — вектор из стрелок, поддерживает 8 направлений (вкл. диагонали);
|
||||
- **однократные** (`toggleWeapon`, `restart`) — срабатывают один раз на нажатие;
|
||||
поэтому они обрабатываются в `consumeActions()` раз в кадр, а не в `step()`.
|
||||
|
||||
- **Pistol (ranged)**: Barrel line + body rect, rotated toward facing
|
||||
direction. Muzzle flash circle at `atkCD > 8`.
|
||||
- **Knife (melee)**: Triangular blade + handle + guard rects, offset
|
||||
in facing direction.
|
||||
## Мир: комнаты и генерация (core/world/)
|
||||
|
||||
## Enemy Types
|
||||
- **Комната** — сетка `COLS×ROWS` тайлов (стены по краю, пол внутри). Двери
|
||||
прорезаются в тайлах только когда комната `cleared` (или это `spawn`).
|
||||
- **RoomMap** — связный набор комнат на сетке `(2·MAP_RADIUS+1)²`, генерируется
|
||||
**случайным блужданием прямо в конструкторе**. Двери ставятся парами: у текущей
|
||||
комнаты в сторону соседа и встречная у соседа (через `OPP`).
|
||||
|
||||
| Type | Size | HP | Speed | Damage | Visual |
|
||||
|--------|------|----|-------|--------|--------|
|
||||
| Normal | 32px | 3 | 1.15 | 1 | Brown block, yellow eyes |
|
||||
| Fast | 26px | 2 | 1.9 | 1 | Red circle, small eyes |
|
||||
| Boss | 46px | 10 | 0.9 | 2 | Large red circle, horns, HP bar |
|
||||
> **Исторический баг №1.** При разнесении на модули у `RoomMap` потеряли вызов
|
||||
> генерации → карта была пустой → игра падала на старте. Теперь генерация в
|
||||
> конструкторе, а тест `tests/roomMap.test.ts` это стережёт.
|
||||
>
|
||||
> **Исторический баг №2.** `OPP` была `{up:'bottom', down:'top'}` (несуществующие
|
||||
> ключи дверей) — встречные двери не ставились. Сейчас `{up:'down', down:'up'}`,
|
||||
> симметрия дверей покрыта тестом.
|
||||
|
||||
## State Management
|
||||
## Коллизии (core/systems/collision.ts)
|
||||
|
||||
- `GAME_OVER` / `WON` — boolean flags checked in loop and render.
|
||||
- `KEYS` — global mutable map, reset on window blur.
|
||||
- Room state (`cleared`, `visited`, `enemies[]`, `tears[]`) — per-room.
|
||||
- Player state (`hp`, `mode`, `facing`, `transCD`, etc.) — single
|
||||
`Player` instance.
|
||||
- Cooldowns (`invTimer`, `atkCD`, `transCD`) decrement each frame.
|
||||
- `isBlocked(room, col, row)` — тайл-стена или выход за границы блокируют, **кроме**
|
||||
дверных проёмов (там граница «прозрачна», чтобы встать на дверь и перейти).
|
||||
- `collidesWall(box, room)` — перебирает тайлы под хитбоксом.
|
||||
- Движение разрешается по осям раздельно: применяем X (откат при коллизии),
|
||||
затем Y. Это даёт «скольжение» вдоль стен.
|
||||
|
||||
## Build Pipeline
|
||||
## Рендер (render/)
|
||||
|
||||
Два наложенных холста (см. `index.html`):
|
||||
|
||||
- **`#game`** — WebGL, мир рисует `ThreeRenderer`.
|
||||
- **`#hud`** — 2D-канвас поверх, `HudOverlay` рисует HP, индикатор режима,
|
||||
счётчик врагов, подпись комнаты, миникарту и оверлеи Game Over/Victory.
|
||||
|
||||
### ThreeRenderer — псевдо-3D (наклонный вид, как в Isaac)
|
||||
|
||||
Логика остаётся 2D-сверху, но РЕНДЕР — псевдо-3D:
|
||||
|
||||
- **Карта координат:** игровая точка `(x, y)` кладётся на пол в 3D как `(x, 0, y)`
|
||||
— пол это плоскость `Y=0`, вверх это `+Y`, «низ» игры (`y`) идёт в глубину (`Z`).
|
||||
Поэтому вся математика и **коллизии ядра без изменений** — псевдо-3D чисто визуальный.
|
||||
- **Камера** — `PerspectiveCamera`, наклонная и фиксированная на комнату (приподнята и
|
||||
отодвинута на «юг», смотрит вниз-вперёд ≈50°). Кадрирует комнату целиком.
|
||||
- **Пол** — одна горизонтальная плоскость с бесшовной текстурой (повтор по сетке).
|
||||
⚠️ Плоскость кладём ПЛАШМЯ через `flatMesh()` (поворот −90° вокруг X). Если забыть
|
||||
поворот — пол встанет вертикально и перспектива «вывернется» (был такой баг).
|
||||
- **Стены** — вертикальные плоскости по периметру с проёмами под двери.
|
||||
- **Персонажи/враги/двери/снаряды** — ВЕРТИКАЛЬНЫЕ спрайты-биллборды (плоскости в XY,
|
||||
нормаль +Z), стоящие на полу: центр на `y = высота/2`, низ на полу. Текстуры — из
|
||||
`assets.ts` (см. ниже). Материалы — `DoubleSide` (наша камера переворачивает Y, иначе
|
||||
грани отсеклись бы) + `alphaTest` для чёткого контура спрайта.
|
||||
- **Двери всегда видимы:** рисуются из `room.doors` независимо от зачистки — закрытые
|
||||
(засов) пока `!cleared`, открытый проём после. Группа комнаты пересобирается при смене
|
||||
комнаты ИЛИ смене флага `cleared`.
|
||||
- **Тени** — отдельный плоский спрайт-«пятно» под каждой сущностью.
|
||||
|
||||
### Ассеты (render/assets.ts)
|
||||
|
||||
`Assets` грузит текстуры из `src/assets/<ключ>.png` (спрайты персонажей, пол, стены,
|
||||
двери, снаряд, эффекты, тень) и кэширует их; если PNG нет — рисует процедурный фолбэк
|
||||
на `<canvas>`, поэтому игра не ломается без ассетов. Dev-сервер отдаёт `/assets/*` из
|
||||
`src/assets/`, прод-сборка копирует их в `dist/assets/`. Подмена/добавление графики —
|
||||
`docs/HOWTO.md` и `docs/ASSET_BRIEF.md`. Ключи ассетов едины в рендере, брифе и именах файлов.
|
||||
|
||||
### Эффекты оружия
|
||||
|
||||
Рендер сам распознаёт события по состоянию (ядро не трогаем):
|
||||
- вспышка из дула — когда `player.atkCD` «подскочил» (выстрел);
|
||||
- искра — когда у врага вырос `hitTimer` (попадание);
|
||||
- облачко-«пуф» — когда мёртвый враг исчезает из комнаты.
|
||||
Эффекты — короткоживущие аддитивные спрайты-биллборды, гаснут по таймеру.
|
||||
|
||||
### Управление ресурсами GPU (важно — иначе утечки)
|
||||
|
||||
- Общие геометрии переиспользуются масштабированием — не плодим геометрии.
|
||||
- Группа комнаты (пол/стены/двери) пересобирается только при смене комнаты/`cleared`;
|
||||
персональные материалы дверей `dispose()`-ятся при пересборке.
|
||||
- Спрайты сущностей и эффекты создаются/удаляются по факту появления/исчезновения
|
||||
(mark-and-sweep), их персональные материалы корректно `dispose()`-ятся.
|
||||
- Общие материалы/геометрии и `Assets` освобождаются один раз в `dispose()`.
|
||||
|
||||
## HiDPI
|
||||
|
||||
`ThreeRenderer` и `HudOverlay` увеличивают буфер под `devicePixelRatio` (до 2×),
|
||||
а рисуют в логических координатах `CW×CH`. CSS-размер холстов фиксирован
|
||||
(`width/height: 100%` внутри `#stage` 880×660) — без этого canvas как
|
||||
replaced-элемент показался бы в размер HiDPI-буфера и вылез бы за рамки.
|
||||
|
||||
## Детерминизм
|
||||
|
||||
Вся случайность идёт через один экземпляр `Rng` (seed). Один и тот же seed даёт
|
||||
одну и ту же карту/спавн — удобно для отладки и обязательно для тестов. Никаких
|
||||
`Math.random()` в `core/` — только `rng`.
|
||||
|
||||
## Меню, правила уровня и кастомные ассеты
|
||||
|
||||
### Поток запуска
|
||||
|
||||
`main.ts` показывает стартовое меню (`ui/StartMenu.ts`, DOM поверх холстов). По
|
||||
клику на уровень создаётся `Game(rules)` и запускается `GameLoop`. Esc во время
|
||||
игры останавливает цикл и снова показывает меню (фон меню перекрывает последний
|
||||
кадр). Рендер (`ThreeRenderer`, `HudOverlay`) и ввод создаются один раз и
|
||||
переиспользуются между забегами; на каждый забег пересоздаётся только `Game` и
|
||||
`GameLoop`. Рендер сам подхватывает смену игры: новая комната ≠ отрисованной →
|
||||
геометрия комнаты пересобирается, меши старых сущностей убираются mark-and-sweep.
|
||||
|
||||
### Правила уровня (core/rules.ts)
|
||||
|
||||
`LevelRules` — данные, параметризующие забег: размер карты, плотность/сила врагов,
|
||||
HP/скорость игрока, опциональный фиксированный seed. `PRESETS` — список для меню
|
||||
(добавил объект → пункт появился). Правила протекают так:
|
||||
|
||||
```
|
||||
bun build src/main.ts --outdir dist --target browser --minify
|
||||
cp src/index.html dist/
|
||||
LevelRules ──► Game(rules) ──► RoomMap(rng, rules) // размер карты
|
||||
└──► Player(rules.player) // HP/скорость
|
||||
└──► spawnEnemies(..., rules) // число/тип/сила врагов (множители)
|
||||
```
|
||||
|
||||
- Bundler: Bun's native bundler (esbuild under the hood).
|
||||
- Target: browser (ES module → IIFE/global wrapper).
|
||||
- Result: single `dist/main.js` (~30 KB) + `dist/index.html`.
|
||||
- Open `dist/index.html` directly in any modern browser.
|
||||
В бесконечном спуске выбранный `rules` остаётся базовым пресетом забега, а
|
||||
`Game` хранит отдельный активный снимок правил текущего этажа. Новая карта и
|
||||
спавн врагов должны брать именно активные правила этажа, иначе данжен растёт, но
|
||||
враги остаются с балансом первого этажа.
|
||||
|
||||
Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в
|
||||
`config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`.
|
||||
`config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов).
|
||||
|
||||
### Тема / ассеты (render/theme.ts)
|
||||
|
||||
Внешний вид мира вынесен в `Theme` (сейчас — только цвета примитивов). `ThreeRenderer`
|
||||
берёт цвета из темы, а не из хардкода, поэтому вид легко подменить, не трогая логику.
|
||||
Это **задел под кастомные ассеты**: чтобы перейти на спрайты/текстуры, расширь `Theme`
|
||||
полями с путями к изображениям, загрузи их `THREE.TextureLoader` и положи в
|
||||
`material.map`. Логика игры при этом не меняется.
|
||||
|
||||
@@ -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/` — подхватится само.
|
||||
@@ -0,0 +1,363 @@
|
||||
# Промпты для ассетов: «Биндинг 2.0»
|
||||
|
||||
Этот файл нужен, чтобы сгенерировать второй набор ассетов в стиле постера
|
||||
`docs/assets/binding_fignyashka.png`, но в формате, который легко положить в игру
|
||||
и при необходимости вырезать руками.
|
||||
|
||||
## Как пользоваться
|
||||
|
||||
1. В генераторе сначала загрузи постер `docs/assets/binding_fignyashka.png` как
|
||||
style reference, если сервис это поддерживает.
|
||||
2. Скопируй **базовый промпт** ниже в начало каждой генерации.
|
||||
3. Ниже по файлу копируй промпт конкретного ассета.
|
||||
4. Сохраняй результат ровно с указанным именем файла.
|
||||
|
||||
Если генератор плохо делает прозрачный фон, проси однотонный chroma key:
|
||||
`pure #00ff00 background, no shadow, no glow touching the background`, затем
|
||||
удаляй фон любым инструментом. Для игры лучше PNG с alpha.
|
||||
|
||||
## Базовый промпт
|
||||
|
||||
```text
|
||||
Use the uploaded poster "Биндим Фигняшку" as the style reference only.
|
||||
|
||||
Create one game asset for a top-down roguelike called "Биндинг 2.0".
|
||||
Style: grotesque dark cartoon dungeon art, chunky hand-painted shapes, high contrast, juicy slime colors, thick uneven black-purple outlines, expressive silly-horror faces, grimy stone dungeon details, saturated neon accents, readable silhouette at small size.
|
||||
|
||||
Technical requirements:
|
||||
- One isolated asset only, centered.
|
||||
- Transparent PNG background with clean alpha.
|
||||
- If transparent background is not possible: pure #00ff00 chroma key background, no gradients, no contact shadow, no glow touching the background.
|
||||
- No text, no letters, no watermark, no UI frame, no mockup, no floor under the object, no baked shadow, unless the specific prompt asks for logo/menu background/floor/wall/shadow.
|
||||
- Keep the whole object inside the canvas with 8-12% padding.
|
||||
- Use the requested exact canvas size and filename.
|
||||
- Front-facing billboard sprite for characters, slightly top-down camera feel, feet aligned near the bottom edge.
|
||||
- Same lighting, palette, outline thickness, and detail density across all assets.
|
||||
- Make it easy to cut out: crisp silhouette, transparent outside pixels, no loose dust outside the silhouette.
|
||||
|
||||
Negative prompt:
|
||||
photorealistic, realistic gore, anime, flat vector icon, pixel art, low contrast, blurry edges, tiny unreadable details, background scene, multiple objects, text, signature, watermark, drop shadow, floor shadow, square UI border, cropped body, cut off feet
|
||||
```
|
||||
|
||||
## Персонажи и враги
|
||||
|
||||
### `player-ranged.png` — игрок с дальним оружием, 192x256
|
||||
|
||||
```text
|
||||
Asset: player-ranged.png, canvas 192x256.
|
||||
|
||||
Small grotesque hero, huge uneven eyes, patched purple hood with little horns, anxious grin, compact body, holding a weird toy-like slime pistol or water gun pointed diagonally down-right. Blue/cyan weapon glow, tiny dungeon grime, funny horror expression. Full body, front-facing billboard sprite, feet at the bottom edge, transparent background.
|
||||
```
|
||||
|
||||
### `player-melee.png` — игрок с ближним оружием, 192x256
|
||||
|
||||
```text
|
||||
Asset: player-melee.png, canvas 192x256.
|
||||
|
||||
Same hero as player-ranged, same face and purple hood, but holding a ridiculous melee weapon: toilet brush, rusty knife, or plunger club. Warmer red/orange accents, aggressive grin, ready to swing. Full body, front-facing billboard sprite, feet at the bottom edge, transparent background.
|
||||
```
|
||||
|
||||
### `enemy-normal.png` — обычный враг, 192x256
|
||||
|
||||
```text
|
||||
Asset: enemy-normal.png, canvas 192x256.
|
||||
|
||||
Round lumpy dungeon monster, terracotta-pink skin, one lazy eye and one tiny eye, small teeth, stubby legs, goofy but hostile. Thick black-purple outline, slime spots, readable chunky silhouette. Full body, front-facing billboard sprite, feet at the bottom edge, transparent background.
|
||||
```
|
||||
|
||||
### `enemy-fast.png` — быстрый враг, 160x213
|
||||
|
||||
```text
|
||||
Asset: enemy-fast.png, canvas 160x213.
|
||||
|
||||
Small twitchy fast monster, red bug-like gobbet, long thin legs, bulging eyes, frantic expression, motion-ready pose but not blurred. Bright red and magenta accents, sharp silhouette. Full body, front-facing billboard sprite, feet at the bottom edge, transparent background.
|
||||
```
|
||||
|
||||
### `enemy-boss.png` — босс, 288x384
|
||||
|
||||
```text
|
||||
Asset: enemy-boss.png, canvas 288x384.
|
||||
|
||||
Huge grotesque boss head-body hybrid, swollen pink-brown flesh, mismatched giant eyes, crown made of junk metal, open screaming mouth, tiny arms with a plunger hammer. Dark dungeon grime, purple slime rim light, very readable massive silhouette. Full body, front-facing billboard sprite, base at bottom edge, transparent background.
|
||||
```
|
||||
|
||||
### `enemy-charger.png` — рывковый враг, 192x256
|
||||
|
||||
```text
|
||||
Asset: enemy-charger.png, canvas 192x256.
|
||||
|
||||
Aggressive charger monster, red-orange flesh, low hunched body, one horn or metal spike on forehead, angry slit eyes, oversized jaw, pose leaning forward like it is about to rush. Thick outline, neon orange highlights. Full body, front-facing billboard sprite, feet at bottom edge, transparent background.
|
||||
```
|
||||
|
||||
### `enemy-tank.png` — тяжёлый враг, 224x288
|
||||
|
||||
```text
|
||||
Asset: enemy-tank.png, canvas 224x288.
|
||||
|
||||
Heavy tank monster, bulky stone-and-flesh body, cracked skull plates, slow stupid face, tiny eyes, big square jaw, metal rivets embedded in skin. Dark brown, grey, bruised purple palette. Full body, front-facing billboard sprite, feet/base at bottom edge, transparent background.
|
||||
```
|
||||
|
||||
### `enemy-shooter.png` — стрелок, 192x256
|
||||
|
||||
```text
|
||||
Asset: enemy-shooter.png, canvas 192x256.
|
||||
|
||||
Shooter monster, bluish sickly body, huge single watery eye, tiny nozzle mouth or fish-gun growth, cheeks full of glowing slime projectile. Cyan/green spit glow, nervous mean expression. Full body, front-facing billboard sprite, feet at bottom edge, transparent background.
|
||||
```
|
||||
|
||||
## Окружение
|
||||
|
||||
### `floor.png` — бесшовный пол, 256x256
|
||||
|
||||
```text
|
||||
Asset: floor.png, canvas 256x256.
|
||||
|
||||
Seamless tileable dungeon floor texture, top-down stone slabs from the poster style, dark grey-brown stones, subtle purple grime, tiny cracks, small slime stains, no strong directional light, no objects, no text. Must tile perfectly on all edges.
|
||||
```
|
||||
|
||||
### `wall.png` — бесшовная стена, 256x256
|
||||
|
||||
```text
|
||||
Asset: wall.png, canvas 256x256.
|
||||
|
||||
Seamless tileable dungeon wall brick texture, cold blue-grey bricks, black-purple mortar, moss and slime in cracks, hand-painted chunky style, no perspective object, no text. Must tile perfectly on all edges.
|
||||
```
|
||||
|
||||
### `door-closed.png` — закрытая дверь, 192x256
|
||||
|
||||
```text
|
||||
Asset: door-closed.png, canvas 192x256.
|
||||
|
||||
Closed dungeon doorway sprite, arched stone frame, chunky wooden planks, heavy golden latch/bar, purple slime outline, front-facing vertical billboard object. Transparent background outside the door frame, no floor, no shadow.
|
||||
```
|
||||
|
||||
### `door-open.png` — открытая дверь, 192x256
|
||||
|
||||
```text
|
||||
Asset: door-open.png, canvas 192x256.
|
||||
|
||||
Open dungeon doorway sprite, arched stone frame, dark black interior void, broken wooden bits, purple slime rim light, front-facing vertical billboard object. Transparent background outside the door frame, no floor, no shadow.
|
||||
```
|
||||
|
||||
### `chest.png` — сундук, 192x192
|
||||
|
||||
```text
|
||||
Asset: chest.png, canvas 192x192.
|
||||
|
||||
Treasure chest object, red-brown wood, gold metal bands, one goofy eye-shaped lock, tiny slime drips, cute-horror style from the poster. Isolated object, three-quarter top-down object view, transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `pickup.png` — универсальный пикап, 128x128
|
||||
|
||||
```text
|
||||
Asset: pickup.png, canvas 128x128.
|
||||
|
||||
Floating weapon pickup marker, glowing golden-purple diamond with slime sparkle, chunky outline, readable at small size. Isolated object, transparent background, no shadow.
|
||||
```
|
||||
|
||||
## Снаряды и эффекты
|
||||
|
||||
### `tear.png` — слеза, 64x64
|
||||
|
||||
```text
|
||||
Asset: tear.png, canvas 64x64.
|
||||
|
||||
Single glowing blue tear projectile, chunky droplet shape, white highlight, cyan edge glow contained inside transparent canvas. No trail, no shadow, transparent background.
|
||||
```
|
||||
|
||||
### `fireball.png` — огненный шар, 64x64
|
||||
|
||||
```text
|
||||
Asset: fireball.png, canvas 64x64.
|
||||
|
||||
Single grotesque fireball projectile, orange-red slime flame orb, white-yellow hot center, small black-purple outline, contained glow, transparent background, no trail.
|
||||
```
|
||||
|
||||
### `beam.png` — луч лазера, 128x128
|
||||
|
||||
```text
|
||||
Asset: beam.png, canvas 128x128.
|
||||
|
||||
Laser impact/beam blob sprite, bright white-cyan center with electric blue-purple rim, circular radial energy burst suitable for additive blending, transparent background, no hard square edges.
|
||||
```
|
||||
|
||||
### `muzzle.png` — вспышка выстрела, 128x128
|
||||
|
||||
```text
|
||||
Asset: muzzle.png, canvas 128x128.
|
||||
|
||||
Muzzle flash effect sprite, white-hot center, yellow-orange starburst, purple edge sparks, soft radial alpha, designed for additive blending. Transparent background.
|
||||
```
|
||||
|
||||
### `spark.png` — искра попадания, 128x128
|
||||
|
||||
```text
|
||||
Asset: spark.png, canvas 128x128.
|
||||
|
||||
Hit spark effect sprite, jagged white-yellow impact burst with tiny purple fragments, clear center, fades to transparent edges, designed for additive blending. Transparent background.
|
||||
```
|
||||
|
||||
### `puff.png` — дымок смерти, 128x128
|
||||
|
||||
```text
|
||||
Asset: puff.png, canvas 128x128.
|
||||
|
||||
Cartoon death puff cloud, grey-white smoke with purple grime, soft edges, funny splat-cloud shape, fades to transparent, no hard outline outside the cloud. Transparent background.
|
||||
```
|
||||
|
||||
### `shadow.png` — тень, 128x64
|
||||
|
||||
```text
|
||||
Asset: shadow.png, canvas 128x64.
|
||||
|
||||
Soft oval blob shadow, black center fading smoothly to full transparency, no colored tint, no hard edge. Transparent PNG, exactly one horizontal ellipse.
|
||||
```
|
||||
|
||||
## HUD и меню
|
||||
|
||||
### `logo.png` — логотип, 900x220
|
||||
|
||||
```text
|
||||
Asset: logo.png, canvas 900x220.
|
||||
|
||||
Logo text in Russian: "Биндим Фигняшку".
|
||||
Use the exact poster lettering vibe: chunky slime letters, green top word "Биндим", orange-yellow bottom word "Фигняшку", purple slime outline, black thick shadow. Transparent background. No extra characters, no subtitle, no watermark.
|
||||
```
|
||||
|
||||
### `menu-bg.png` — фон меню, 880x660
|
||||
|
||||
```text
|
||||
Asset: menu-bg.png, canvas 880x660.
|
||||
|
||||
Dark dungeon menu background in the same poster style, top-down room fragments, dim torches, purple slime, scattered silly-horror props. No text, no logo, no centered character, leave the central area readable for UI overlay. Full rectangular background, not transparent.
|
||||
```
|
||||
|
||||
### `heart-full.png` — полное сердце, 48x48
|
||||
|
||||
```text
|
||||
Asset: heart-full.png, canvas 48x48.
|
||||
|
||||
Full HP heart icon, red fleshy cartoon heart, thick dark outline, tiny slime highlight, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `heart-half.png` — половина сердца, 48x48
|
||||
|
||||
```text
|
||||
Asset: heart-half.png, canvas 48x48.
|
||||
|
||||
Half HP heart icon matching heart-full, left half full red flesh, right half dark empty husk, thick dark outline, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `heart-empty.png` — пустое сердце, 48x48
|
||||
|
||||
```text
|
||||
Asset: heart-empty.png, canvas 48x48.
|
||||
|
||||
Empty HP heart icon matching heart-full, dark cracked heart outline with hollow center, subtle purple grime, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `icon-ranged.png` — иконка дальнего режима, 48x48
|
||||
|
||||
```text
|
||||
Asset: icon-ranged.png, canvas 48x48.
|
||||
|
||||
Ranged mode HUD icon, tiny goofy slime pistol firing one blue tear, thick outline, high contrast, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `icon-melee.png` — иконка ближнего режима, 48x48
|
||||
|
||||
```text
|
||||
Asset: icon-melee.png, canvas 48x48.
|
||||
|
||||
Melee mode HUD icon, tiny ridiculous knife/plunger/brush weapon, red-orange accent, thick outline, high contrast, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
## Иконки оружия
|
||||
|
||||
### `weapon-icon-tears.png`, 48x48
|
||||
|
||||
```text
|
||||
Asset: weapon-icon-tears.png, canvas 48x48.
|
||||
|
||||
Weapon icon for "Слёзы": single blue tear orb with white highlight, chunky outline, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `weapon-icon-melee.png`, 48x48
|
||||
|
||||
```text
|
||||
Asset: weapon-icon-melee.png, canvas 48x48.
|
||||
|
||||
Weapon icon for "Кулак": goofy clenched fist, bruised peach color, thick dark outline, tiny slime detail, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `weapon-icon-shotgun.png`, 48x48
|
||||
|
||||
```text
|
||||
Asset: weapon-icon-shotgun.png, canvas 48x48.
|
||||
|
||||
Weapon icon for "Дробовик": three small glowing pellets spreading outward, yellow-orange centers, dark outline, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `weapon-icon-axe.png`, 48x48
|
||||
|
||||
```text
|
||||
Asset: weapon-icon-axe.png, canvas 48x48.
|
||||
|
||||
Weapon icon for "Топор": rusty cartoon axe with chipped metal head and short handle, purple grime, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `weapon-icon-staff.png`, 48x48
|
||||
|
||||
```text
|
||||
Asset: weapon-icon-staff.png, canvas 48x48.
|
||||
|
||||
Weapon icon for "Посох": crooked wooden staff with glowing orange fire gem, tiny slime drips, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `weapon-icon-whip.png`, 48x48
|
||||
|
||||
```text
|
||||
Asset: weapon-icon-whip.png, canvas 48x48.
|
||||
|
||||
Weapon icon for "Хлыст": curled leather whip, exaggerated curve, warm brown with purple rim, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `weapon-icon-bomb.png`, 48x48
|
||||
|
||||
```text
|
||||
Asset: weapon-icon-bomb.png, canvas 48x48.
|
||||
|
||||
Weapon icon for "Бомба": round black cartoon bomb with lit fuse, orange spark, silly eye-like rivet, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `weapon-icon-boomerang.png`, 48x48
|
||||
|
||||
```text
|
||||
Asset: weapon-icon-boomerang.png, canvas 48x48.
|
||||
|
||||
Weapon icon for "Бумеранг": crooked wooden boomerang with teeth marks and purple slime stripe, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
### `weapon-icon-laser.png`, 48x48
|
||||
|
||||
```text
|
||||
Asset: weapon-icon-laser.png, canvas 48x48.
|
||||
|
||||
Weapon icon for "Лазер": compact blue-white laser emitter crystal or ray gun barrel, cyan glow contained inside icon, thick outline, readable at 24 px. Transparent background, no shadow.
|
||||
```
|
||||
|
||||
## После генерации
|
||||
|
||||
Сложи файлы второго набора в отдельную папку, например:
|
||||
|
||||
```text
|
||||
src/assets-binding-2/
|
||||
```
|
||||
|
||||
Потом можно добавить выбор в меню:
|
||||
|
||||
- `Классика` -> текущая папка `src/assets/`;
|
||||
- `Биндинг 2.0` -> новая папка `src/assets-binding-2/`.
|
||||
|
||||
Когда ассеты будут готовы, самый чистый следующий шаг в коде: дать `Assets` параметр
|
||||
`packPath` и прокинуть выбранный пак из `StartMenu` в `ThreeRenderer`/`HudOverlay`.
|
||||
@@ -0,0 +1,176 @@
|
||||
# HOWTO — рецепты доработки
|
||||
|
||||
Практические инструкции «как сделать X». Каждый рецепт перечисляет все файлы,
|
||||
которые нужно тронуть. После любой правки — `bun run check` и проверка в браузере
|
||||
(`bun run dev`). Общие правила — в [`CLAUDE.md`](../CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Покрутить баланс (скорость, HP, урон, число врагов)
|
||||
|
||||
Всё в одном файле — **`src/config.ts`**. Например:
|
||||
|
||||
- игрок быстрее: `PLAYER.speed`;
|
||||
- больше HP у босса: `ENEMY_STATS.boss.hp`;
|
||||
- больше врагов в комнате: `SPAWN.normalMin` / `SPAWN.normalExtra`;
|
||||
- крупнее данжен: `MIN_ROOMS` / `EXTRA_ROOMS` / `MAP_RADIUS`.
|
||||
|
||||
Код менять не нужно. Это и есть смысл `config.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Добавить новый тип врага (например, `tank`)
|
||||
|
||||
1. **`src/core/types.ts`** — добавь в union: `export type EnemyType = 'normal' | 'fast' | 'boss' | 'tank';`
|
||||
2. **`src/config.ts`** — строка характеристик в `ENEMY_STATS`:
|
||||
```ts
|
||||
tank: { size: 40, hp: 8, speed: 0.7, damage: 2 },
|
||||
```
|
||||
3. **`src/core/systems/spawner.ts`** — реши, когда он спавнится (логика выбора типа
|
||||
в начале цикла). Напр. с шансом: `rng.chance(0.15) ? 'tank' : rng.chance(ENEMY.fastChance) ? 'fast' : 'normal'`.
|
||||
4. **`src/render/assets.ts`** — добавь ключ в `SpriteKey` и ветку в `Assets.sprite()`
|
||||
(рисуется через `drawCharacter(...)` с твоими цветами).
|
||||
5. **`src/render/ThreeRenderer.ts`** — добавь строку в `enemyMatKey`, сопоставив новый
|
||||
`Enemy['type']` этому `SpriteKey` (без этого не пройдёт проверка типов).
|
||||
|
||||
ИИ, урон, отбрасывание, мигание при попадании — общие, их трогать не нужно.
|
||||
Добавь тест в `tests/spawner.test.ts`, если ввёл особое правило спавна.
|
||||
|
||||
---
|
||||
|
||||
## 3. Добавить новое поведение врага (особый ИИ)
|
||||
|
||||
Сейчас все враги ведут себя одинаково (преследование игрока) в
|
||||
`Game.updateEnemies` (`src/core/Game.ts`). Чтобы развести поведение:
|
||||
|
||||
- по `e.type` внутри `updateEnemies` развилкой (просто, для 2–3 типов), **или**
|
||||
- вынеси ИИ в `src/core/systems/ai.ts` как функции `update<Type>(enemy, player, room)`
|
||||
и диспетчеризуй по типу (чище, когда типов много).
|
||||
|
||||
Держи это в `core/` (без рендера) и старайся писать чистыми функциями — их легко
|
||||
покрыть тестом.
|
||||
|
||||
---
|
||||
|
||||
## 4. Добавить оружие / третий режим боя
|
||||
|
||||
Режимы — это `MODE_RANGED = 0` / `MODE_MELEE = 1` и тип `CombatMode = 0 | 1`.
|
||||
Для третьего режима:
|
||||
|
||||
1. **`src/config.ts`** — константа `MODE_X = 2` и блок баланса.
|
||||
2. **`src/core/types.ts`** — расширь `CombatMode` (`0 | 1 | 2`).
|
||||
3. **`src/core/Game.ts`** — в `handleAttack` добавь ветку создания нужного снаряда/
|
||||
эффекта; в `consumeActions` смена оружия циклом по всем режимам.
|
||||
4. **`src/render/HudOverlay.ts`** — подпись/иконка режима.
|
||||
5. Если это новый вид снаряда — заведи сущность в `src/core/entities/` и обновляй
|
||||
её в `Game.step` (по образцу `Projectile`/`updateTears`), а в `ThreeRenderer`
|
||||
добавь её отрисовку.
|
||||
|
||||
---
|
||||
|
||||
## 5. Добавить тип комнаты (например, `shop`)
|
||||
|
||||
1. **`src/core/types.ts`** — добавь в `RoomType`.
|
||||
2. **`src/core/world/RoomMap.ts`** — правило назначения типа в `generate()`.
|
||||
3. **`src/core/systems/spawner.ts`** — сколько врагов (часто 0).
|
||||
4. **`src/core/Game.ts`** — если врагов 0, комната уже авто-зачищается при входе
|
||||
(см. `enterRoom`); особая логика комнаты — здесь же.
|
||||
5. **`src/render/HudOverlay.ts`** — цвет на миникарте и подпись (`drawMinimap`/`drawHud`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Поменять управление
|
||||
|
||||
**`src/input/KeyboardController.ts`** — метод `poll()` (маппинг клавиш на
|
||||
`InputState`) и набор `PREVENT` (клавиши, у которых гасим поведение браузера).
|
||||
Логику игры это не затрагивает — она читает только `InputState`.
|
||||
|
||||
Геймпад/тач: сделай новый контроллер с тем же `poll(): InputState` и подставь его
|
||||
в `main.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Добавить уровень (пресет правил) в меню
|
||||
|
||||
**`src/core/rules.ts`** — добавь объект в массив `PRESETS`. Он сразу появится
|
||||
кнопкой в стартовом меню (меню строится из `PRESETS`). Например:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: 'swarm',
|
||||
name: 'Орда',
|
||||
description: 'Очень много слабых быстрых врагов.',
|
||||
map: { minRooms: 10, extraRooms: 4, mapRadius: 4 },
|
||||
player: { maxHp: 6, speed: 3.2 },
|
||||
enemies: { densityMul: 2.5, fastChance: 0.9, hpMul: 0.5, speedMul: 1.2, bossHpMul: 1 },
|
||||
},
|
||||
```
|
||||
|
||||
- `seed` (необязательный) фиксирует данжен — одинаковый забег каждый раз.
|
||||
- Нужен новый «рычаг» (например, шанс сокровищниц)? Добавь поле в `LevelRules` и
|
||||
читай его там, где раньше брал константу из `config.ts` (генерация — `RoomMap`,
|
||||
спавн — `spawner`). Тест на влияние правил — в `tests/game.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Кастомные ассеты (спрайты, текстуры, тема)
|
||||
|
||||
Графика грузится из PNG в **`src/assets/`** (класс `Assets` в `src/render/assets.ts`
|
||||
тянет `assets/<ключ>.png` через `THREE.TextureLoader`); если файла нет — рисуется
|
||||
процедурный фолбэк на canvas, и игра не ломается.
|
||||
|
||||
- **Свой/новый ассет:** просто положи PNG с именем `<ключ>.png` в **`src/assets/`**
|
||||
(dev-сервер отдаёт их сразу, прод-сборка копирует в `dist/assets`). Код менять не нужно.
|
||||
Ключи = именам файлов (`player-ranged`, `enemy-boss`, `floor`, `door-closed`, `tear`,
|
||||
`heart-full`, `icon-ranged`, …); полный список и промпт для дизайн-ИИ — в **`docs/ASSET_BRIEF.md`**.
|
||||
- **Запасной рисунок (фолбэк):** функции `drawX()` в `assets.ts` — правь их, только если
|
||||
нужен другой плейсхолдер на случай отсутствия PNG.
|
||||
- **Цвета/тинты/фон** (не текстуры) — в **`src/render/theme.ts`** (`Theme` + `DEFAULT_THEME`).
|
||||
- **HUD-ассеты** (сердечки `heart-*`, иконки `icon-*`) грузит `HudOverlay`; меню (`logo`,
|
||||
`menu-bg`) — `index.html`. Те же имена в `src/assets/`.
|
||||
|
||||
- **Своя палитра:** сделай ещё один объект `Theme` и передай его в
|
||||
`new ThreeRenderer(canvas, myTheme)` в `main.ts`. Сейчас рендер создаётся один
|
||||
раз при старте с темой по умолчанию и переиспользуется между забегами, поэтому
|
||||
чтобы **выбирать тему по правилам уровня**, нужно либо перенести
|
||||
`new ThreeRenderer(...)` внутрь `startGame(rules)` (и `dispose()` предыдущий),
|
||||
либо добавить рендеру метод `setTheme(theme)`.
|
||||
- **Перейти на спрайты/текстуры:** расширь `Theme` полями с путями к картинкам
|
||||
(см. комментарий-задел в `theme.ts`), загрузи их через `THREE.TextureLoader` в
|
||||
`ThreeRenderer` и положи текстуру в `material.map` соответствующих мешей вместо
|
||||
(или вместе с) `color`. Логика игры при этом НЕ меняется — это чисто рендер.
|
||||
|
||||
---
|
||||
|
||||
## 9. Поменять или нарастить рендер (вплоть до 3D)
|
||||
|
||||
Рендер изолирован за интерфейсом **`src/render/Renderer.ts`** (`render(game, alpha)`
|
||||
+ `dispose()`). Варианты:
|
||||
|
||||
- **Доработать вид** (спрайты, текстуры, частицы) — внутри `ThreeRenderer` (камера
|
||||
уже наклонная `PerspectiveCamera`, сущности — биллборд-спрайты).
|
||||
- **Усилить 3D** — для настоящего объёма замени `MeshBasicMaterial` (он без света)
|
||||
на `MeshStandardMaterial`, добавь источники света и объёмные меши вместо плоскостей.
|
||||
Мир рисуется по тем же координатам сущностей из `Game` — логику менять не нужно.
|
||||
- **Другой рендер целиком** (например, Canvas2D для отладки) — реализуй `Renderer`
|
||||
и подставь в `main.ts`. Ядро не трогается вообще.
|
||||
|
||||
Помни про управление ресурсами (правило 6 в `CLAUDE.md`).
|
||||
|
||||
---
|
||||
|
||||
## 10. Отладка
|
||||
|
||||
- В консоли браузера доступен `game` — текущий `Game`. Примеры:
|
||||
```js
|
||||
game.player.hp = 99 // бессмертие на тест
|
||||
game.curRoom.enemies.length // сколько врагов в комнате
|
||||
game.reset() // новая карта
|
||||
```
|
||||
- **Детерминизм:** чтобы воспроизвести конкретную карту, задай `seed` правилам —
|
||||
проще всего поставить `seed: 42` нужному пресету в `core/rules.ts` (см. пресет
|
||||
`daily`). Тот же seed — та же генерация и спавн, в т.ч. после рестарта.
|
||||
В тестах можно передать свой ГПСЧ **вторым** аргументом: `new Game(rules, new Rng(42))`
|
||||
(первый аргумент — правила, не ГПСЧ).
|
||||
- **Тесты ядра** гоняются мгновенно и без браузера: `bun test`. Логику отлаживай
|
||||
тестом, а не кликами.
|
||||
|
After Width: | Height: | Size: 372 KiB |
@@ -1,702 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Dungeon Crawl — Ranged / Melee</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#0a0a0a;display:flex;justify-content:center;align-items:center;height:100vh;font-family:monospace;overflow:hidden;user-select:none}
|
||||
canvas{display:block;border:1px solid #222;border-radius:2px;cursor:none}
|
||||
</style>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Биндим Фигняшку</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: monospace;
|
||||
color: #ccc;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
/* Сцена = два наложенных холста: WebGL-мир снизу, 2D-HUD сверху.
|
||||
width/height: 100% обязательны — иначе canvas (replaced-элемент)
|
||||
показывается в размер своего HiDPI-буфера и вылезает за рамки. */
|
||||
#stage { position: relative; width: 880px; height: 660px; border: 1px solid #222; }
|
||||
#stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#hud { pointer-events: none; }
|
||||
|
||||
/* Стартовое меню — поверх холстов, перекрывает сцену своим фоном. */
|
||||
#menu {
|
||||
position: absolute; inset: 0; z-index: 10;
|
||||
/* фон-картинка с тёмным затемнением для читаемости; фолбэк — тёмный цвет */
|
||||
background: linear-gradient(rgba(10,10,15,0.7), rgba(10,10,15,0.82)),
|
||||
url(assets/menu-bg.png) center / cover no-repeat, #0a0a0f;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 14px; padding: 24px;
|
||||
}
|
||||
#menu h1 { font-size: 40px; letter-spacing: 4px; color: #c9b27a; margin-bottom: 4px; }
|
||||
#menu-logo { display: block; width: min(560px, 80%); height: auto; }
|
||||
.menu-sub { color: #888; margin-bottom: 6px; }
|
||||
.menu-packs { display: flex; gap: 8px; margin-bottom: 2px; }
|
||||
.menu-pack {
|
||||
cursor: pointer;
|
||||
background: rgba(12,12,18,0.86); color: #aaa;
|
||||
border: 1px solid #333; border-radius: 4px;
|
||||
padding: 7px 12px; font-family: monospace; font-size: 12px;
|
||||
transition: background .12s, border-color .12s, color .12s;
|
||||
}
|
||||
.menu-pack:hover { background: #1d1d2c; border-color: #5a78c0; color: #ddd; }
|
||||
.menu-pack.is-selected { color: #f0d887; border-color: #8f6f25; background: rgba(42,32,12,0.88); }
|
||||
#menu-presets { display: flex; flex-direction: column; gap: 10px; width: 360px; max-width: 90%; }
|
||||
.menu-preset {
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
text-align: left; cursor: pointer;
|
||||
background: #15151f; color: #ddd;
|
||||
border: 1px solid #333; border-radius: 4px;
|
||||
padding: 12px 14px; font-family: monospace; font-size: 14px;
|
||||
transition: background .12s, border-color .12s;
|
||||
}
|
||||
.menu-preset:hover { background: #1d1d2c; border-color: #5a78c0; }
|
||||
.menu-preset-name { font-weight: bold; color: #cdd6f4; }
|
||||
.menu-preset-desc { font-size: 11px; color: #888; }
|
||||
.menu-hint { color: #555; font-size: 12px; margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="880" height="660"></canvas>
|
||||
<script>
|
||||
// ============================================================
|
||||
// CONSTANTS
|
||||
// ============================================================
|
||||
const CW=880,CH=660,TILE=44,COLS=15,ROWS=11;
|
||||
const RW=COLS*TILE,RH=ROWS*TILE;
|
||||
const OX=(CW-RW)/2,OY=80;
|
||||
<div id="stage">
|
||||
<canvas id="game" width="880" height="660"></canvas>
|
||||
<canvas id="hud" width="880" height="660"></canvas>
|
||||
|
||||
const T_WALL=0,T_FLOOR=1,T_DOOR=2;
|
||||
const MODE_RANGED=0,MODE_MELEE=1;
|
||||
|
||||
const DIR={
|
||||
up:[0,-1], down:[0,1], left:[-1,0], right:[1,0]
|
||||
};
|
||||
const OPP={up:'bottom',down:'top',left:'right',right:'left'};
|
||||
|
||||
const DOOR={
|
||||
up: { cols:[6,7,8], row:0, cx:7, cy:0 },
|
||||
down: { cols:[6,7,8], row:10, cx:7, cy:10 },
|
||||
left: { col:0, rows:[4,5,6], cx:0, cy:5 },
|
||||
right:{ col:14, rows:[4,5,6], cx:14,cy:5 }
|
||||
};
|
||||
|
||||
let KEYS={},GAME_OVER=false,WON=false;
|
||||
|
||||
// ============================================================
|
||||
// UTILITY
|
||||
// ============================================================
|
||||
function shuffle(a){for(let i=a.length-1;i>0;i--){const j=Math.random()*i|0;[a[i],a[j]]=[a[j],a[i]]}return a}
|
||||
function rand(a,b){return Math.random()*(b-a)+a}
|
||||
function ri(a,b){return Math.floor(rand(a,b+1))}
|
||||
function dist(x1,y1,x2,y2){return Math.hypot(x2-x1,y2-y1)}
|
||||
function overlap(a,b){return a.x<b.x+b.w&&a.x+a.w>b.x&&a.y<b.y+b.h&&a.y+a.h>b.y}
|
||||
|
||||
// ============================================================
|
||||
// ROOM
|
||||
// ============================================================
|
||||
class Room{
|
||||
constructor(c,r,type){
|
||||
this.c=c;this.r=r;this.type=type;
|
||||
this.doors={up:false,down:false,left:false,right:false};
|
||||
this.visited=false;this.cleared=false;
|
||||
this.enemies=[];this.tears=[];
|
||||
this.tiles=[];this.buildTiles();
|
||||
}
|
||||
buildTiles(){
|
||||
for(let r=0;r<ROWS;r++){
|
||||
this.tiles[r]=[];
|
||||
for(let c=0;c<COLS;c++){
|
||||
this.tiles[r][c]=(r===0||r===ROWS-1||c===0||c===COLS-1)?T_WALL:T_FLOOR;
|
||||
}
|
||||
}
|
||||
if(this.cleared)this.placeDoors();
|
||||
}
|
||||
placeDoors(){
|
||||
if(this.doors.up)for(const c of DOOR.up.cols)this.tiles[DOOR.up.row][c]=T_DOOR;
|
||||
if(this.doors.down)for(const c of DOOR.down.cols)this.tiles[DOOR.down.row][c]=T_DOOR;
|
||||
if(this.doors.left)for(const r of DOOR.left.rows)this.tiles[r][DOOR.left.col]=T_DOOR;
|
||||
if(this.doors.right)for(const r of DOOR.right.rows)this.tiles[r][DOOR.right.col]=T_DOOR;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ROOM MAP
|
||||
// ============================================================
|
||||
class RoomMap{
|
||||
constructor(){this.rooms={};this.generate()}
|
||||
key(c,r){return c+','+r}
|
||||
get(c,r){return this.rooms[this.key(c,r)]}
|
||||
has(c,r){return !!this.get(c,r)}
|
||||
add(c,r,t){const o=new Room(c,r,t);this.rooms[this.key(c,r)]=o;return o}
|
||||
|
||||
hasBoss(){return Object.values(this.rooms).some(r=>r.type==='boss')}
|
||||
|
||||
generate(){
|
||||
this.add(0,0,'spawn');
|
||||
let frontier=[[0,0]],count=1,target=8+ri(0,4);
|
||||
const dirs=[['up',0,-1],['down',0,1],['left',-1,0],['right',1,0]];
|
||||
while(frontier.length>0&&count<target){
|
||||
const idx=ri(0,frontier.length-1);
|
||||
const [cr,cc]=frontier[idx];
|
||||
shuffle(dirs);
|
||||
let added=false;
|
||||
for(const [d,dc,dr] of dirs){
|
||||
if(count>=target)break;
|
||||
const nc=cr+dc,nr=cc+dr;
|
||||
if(Math.abs(nc)>3||Math.abs(nr)>3||this.has(nc,nr))continue;
|
||||
let type='normal';
|
||||
if((count===target-1||(Math.random()<0.2&&count>=3))&&!this.hasBoss())type='boss';
|
||||
else if(Math.random()<0.12&&count>=2)type='treasure';
|
||||
this.add(nc,nr,type);
|
||||
this.get(cr,cc).doors[d]=true;
|
||||
this.get(nc,nr).doors[OPP[d]]=true;
|
||||
frontier.push([nc,nr]);count++;added=true;
|
||||
}
|
||||
if(!added)frontier.splice(idx,1);
|
||||
}
|
||||
if(!this.hasBoss()){
|
||||
const cs=Object.values(this.rooms).filter(r=>r.type==='normal');
|
||||
if(cs.length>0)cs[ri(0,cs.length-1)].type='boss';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ENTITIES
|
||||
// ============================================================
|
||||
class Player{
|
||||
constructor(){
|
||||
this.x=0;this.y=0;this.w=26;this.h=26;
|
||||
this.speed=3.2;this.hp=6;this.maxHp=6;
|
||||
this.mode=MODE_RANGED;this.facing='up';this.moveDir='up';
|
||||
this.atkCD=0;this.invTimer=0;this.transCD=0;
|
||||
}
|
||||
get box(){return{x:this.x-this.w/2,y:this.y-this.h/2,w:this.w,h:this.h}}
|
||||
}
|
||||
|
||||
class Enemy{
|
||||
constructor(x,y,type){
|
||||
this.x=x;this.y=y;this.type=type;
|
||||
this.w=type==='boss'?46:type==='fast'?26:32;
|
||||
this.h=this.w;
|
||||
this.hp=type==='boss'?10:type==='fast'?2:3;
|
||||
this.maxHp=this.hp;
|
||||
this.speed=type==='boss'?0.9:type==='fast'?1.9:1.15;
|
||||
this.damage=type==='boss'?2:1;
|
||||
this.knx=0;this.kny=0;this.hitTimer=0;this.atkTimer=0;
|
||||
}
|
||||
get box(){return{x:this.x-this.w/2,y:this.y-this.h/2,w:this.w,h:this.h}}
|
||||
get alive(){return this.hp>0}
|
||||
}
|
||||
|
||||
class Tear{
|
||||
constructor(x,y,dx,dy){
|
||||
this.x=x;this.y=y;this.dx=dx;this.dy=dy;
|
||||
this.r=5;this.speed=7;this.damage=1;this.life=80;
|
||||
}
|
||||
get alive(){return this.life>0}
|
||||
}
|
||||
|
||||
class MeleeSwing{
|
||||
constructor(x,y,dir){
|
||||
this.dir=dir;this.life=10;this.damage=2;this.kb=10;
|
||||
const d=22,s=50;
|
||||
switch(dir){
|
||||
case'up': this.box={x:x-s/2,y:y-d-s,w:s,h:s};break;
|
||||
case'down': this.box={x:x-s/2,y:y+d,w:s,h:s};break;
|
||||
case'left': this.box={x:x-d-s,y:y-s/2,w:s,h:s};break;
|
||||
case'right':this.box={x:x+d,y:y-s/2,w:s,h:s};break;
|
||||
}
|
||||
}
|
||||
get alive(){return this.life>0}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GAME
|
||||
// ============================================================
|
||||
let game=null;
|
||||
|
||||
class Game{
|
||||
constructor(){
|
||||
this.canvas=document.getElementById('game');
|
||||
this.ctx=this.canvas.getContext('2d');
|
||||
this.map=new RoomMap();
|
||||
this.player=new Player();
|
||||
this.cc=0;this.cr=0;this.meleeSwing=null;
|
||||
this.setupInput();
|
||||
this.enterRoom('up');
|
||||
this.update();
|
||||
}
|
||||
get cur(){return this.map.get(this.cc,this.cr)}
|
||||
|
||||
setupInput(){
|
||||
window.addEventListener('keydown',e=>{
|
||||
if((e.key==='Tab'||e.key==='q'||e.key==='Q')&&!GAME_OVER&&!WON){
|
||||
e.preventDefault();this.player.mode=this.player.mode===MODE_RANGED?MODE_MELEE:MODE_RANGED;
|
||||
}
|
||||
if(e.key==='r'||e.key==='R'){if(GAME_OVER||WON)this.restart()}
|
||||
KEYS[e.key]=true;
|
||||
if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight',' '].includes(e.key))e.preventDefault();
|
||||
});
|
||||
window.addEventListener('keyup',e=>{KEYS[e.key]=false});
|
||||
window.addEventListener('blur',()=>{KEYS={}});
|
||||
}
|
||||
|
||||
restart(){
|
||||
GAME_OVER=false;WON=false;
|
||||
this.map=new RoomMap();this.player=new Player();
|
||||
this.cc=0;this.cr=0;this.meleeSwing=null;
|
||||
this.enterRoom('up');
|
||||
}
|
||||
|
||||
enterRoom(fromDir){
|
||||
const room=this.cur;
|
||||
room.visited=true;
|
||||
this.entryDir=fromDir;
|
||||
const d=DOOR[fromDir],[ddc,ddr]=DIR[fromDir];
|
||||
|
||||
// place player 1 tile inside from the door center
|
||||
this.player.x=OX+d.cx*TILE+TILE/2-ddc*TILE;
|
||||
this.player.y=OY+d.cy*TILE+TILE/2-ddr*TILE;
|
||||
this.player.facing=fromDir;
|
||||
this.player.invTimer=20;
|
||||
this.player.transCD=15; // prevent re-transition for 15 frames
|
||||
this.meleeSwing=null;
|
||||
|
||||
room.buildTiles();
|
||||
room.enemies=[];
|
||||
room.tears=[];
|
||||
|
||||
if(!room.cleared&&room.type!=='spawn'){
|
||||
this.spawnEnemies(room,fromDir);
|
||||
}else{
|
||||
room.cleared=true;
|
||||
room.buildTiles();
|
||||
}
|
||||
}
|
||||
|
||||
spawnEnemies(room,entryDir){
|
||||
const count=room.type==='boss'?1:room.type==='treasure'?0:2+ri(0,2);
|
||||
for(let i=0;i<count;i++){
|
||||
let tries=0,x,y,ok;
|
||||
const type=room.type==='boss'?'boss':Math.random()<0.3?'fast':'normal';
|
||||
do{
|
||||
x=OX+2*TILE+rand(0,COLS-4)*TILE;
|
||||
y=OY+2*TILE+rand(0,ROWS-4)*TILE;
|
||||
ok=true;
|
||||
const ed=DOOR[entryDir],dx=OX+ed.cx*TILE+TILE/2,dy=OY+ed.cy*TILE+TILE/2;
|
||||
if(dist(x,y,dx,dy)<180)ok=false;
|
||||
for(const e of room.enemies)if(dist(x,y,e.x,e.y)<60)ok=false;
|
||||
if(dist(x,y,this.player.x,this.player.y)<150)ok=false;
|
||||
tries++;
|
||||
}while(!ok&&tries<100);
|
||||
room.enemies.push(new Enemy(x,y,type));
|
||||
}
|
||||
}
|
||||
|
||||
isBlocked(room,col,row){
|
||||
// allow passing through the room boundary at door openings
|
||||
if(row<0&&room.doors.up&&DOOR.up.cols.includes(col))return false;
|
||||
if(row>=ROWS&&room.doors.down&&DOOR.down.cols.includes(col))return false;
|
||||
if(col<0&&room.doors.left&&DOOR.left.rows.includes(row))return false;
|
||||
if(col>=COLS&&room.doors.right&&DOOR.right.rows.includes(row))return false;
|
||||
if(row<0||row>=ROWS||col<0||col>=COLS)return true;
|
||||
return room.tiles[row][col]===T_WALL;
|
||||
}
|
||||
|
||||
collidesWall(ent,room){
|
||||
const l=Math.floor((ent.x-ent.w/2-OX)/TILE);
|
||||
const r=Math.floor((ent.x+ent.w/2-OX)/TILE);
|
||||
const t=Math.floor((ent.y-ent.h/2-OY)/TILE);
|
||||
const b=Math.floor((ent.y+ent.h/2-OY)/TILE);
|
||||
for(let row=t;row<=b;row++)
|
||||
for(let col=l;col<=r;col++)
|
||||
if(this.isBlocked(room,col,row))return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- TRANSITION: player stands on a door tile and moves into it ---
|
||||
checkTransition(){
|
||||
if(GAME_OVER||WON)return;
|
||||
if(this.player.transCD>0)return;
|
||||
const p=this.player,room=this.cur;
|
||||
if(!room.cleared)return;
|
||||
const col=Math.floor((p.x-OX)/TILE),row=Math.floor((p.y-OY)/TILE);
|
||||
|
||||
// top door
|
||||
if(row===0&&room.doors.up&&DOOR.up.cols.includes(col)&&(KEYS['w']||KEYS['W']||KEYS['ArrowUp'])){
|
||||
if(this.map.has(this.cc,this.cr-1)){this.cr--;this.enterRoom('down');return}
|
||||
}
|
||||
// bottom door
|
||||
if(row===ROWS-1&&room.doors.down&&DOOR.down.cols.includes(col)&&(KEYS['s']||KEYS['S']||KEYS['ArrowDown'])){
|
||||
if(this.map.has(this.cc,this.cr+1)){this.cr++;this.enterRoom('up');return}
|
||||
}
|
||||
// left door
|
||||
if(col===0&&room.doors.left&&DOOR.left.rows.includes(row)&&(KEYS['a']||KEYS['A']||KEYS['ArrowLeft'])){
|
||||
if(this.map.has(this.cc-1,this.cr)){this.cc--;this.enterRoom('right');return}
|
||||
}
|
||||
// right door
|
||||
if(col===COLS-1&&room.doors.right&&DOOR.right.rows.includes(row)&&(KEYS['d']||KEYS['D']||KEYS['ArrowRight'])){
|
||||
if(this.map.has(this.cc+1,this.cr)){this.cc++;this.enterRoom('left');return}
|
||||
}
|
||||
}
|
||||
|
||||
update(){
|
||||
if(!GAME_OVER&&!WON)this.tick();
|
||||
this.render();
|
||||
requestAnimationFrame(()=>this.update());
|
||||
}
|
||||
|
||||
tick(){
|
||||
const room=this.cur,p=this.player;
|
||||
|
||||
if(p.invTimer>0)p.invTimer--;
|
||||
if(p.atkCD>0)p.atkCD--;
|
||||
if(p.transCD>0)p.transCD--;
|
||||
|
||||
// --- MOVEMENT ---
|
||||
let mx=0,my=0;
|
||||
if(KEYS['w']||KEYS['W'])my=-1;
|
||||
if(KEYS['s']||KEYS['S'])my=1;
|
||||
if(KEYS['a']||KEYS['A'])mx=-1;
|
||||
if(KEYS['d']||KEYS['D'])mx=1;
|
||||
if(mx!==0||my!==0){
|
||||
const len=Math.hypot(mx,my);mx/=len;my/=len;
|
||||
if(my<0)p.moveDir='up';else if(my>0)p.moveDir='down';
|
||||
if(mx<0)p.moveDir='left';else if(mx>0)p.moveDir='right';
|
||||
const dx=mx*p.speed,dy=my*p.speed;
|
||||
p.x+=dx;if(this.collidesWall(p,room))p.x-=dx;
|
||||
p.y+=dy;if(this.collidesWall(p,room))p.y-=dy;
|
||||
}
|
||||
|
||||
// --- ATTACK ---
|
||||
let ax=0,ay=0;
|
||||
if(KEYS['ArrowUp'])ax=0,ay=-1;
|
||||
else if(KEYS['ArrowDown'])ax=0,ay=1;
|
||||
else if(KEYS['ArrowLeft'])ax=-1,ay=0;
|
||||
else if(KEYS['ArrowRight'])ax=1,ay=0;
|
||||
else if(KEYS[' ']||KEYS['Space']){const[fx,fy]=DIR[p.moveDir];ax=fx;ay=fy}
|
||||
|
||||
if((ax!==0||ay!==0)&&p.atkCD<=0){
|
||||
const len=Math.hypot(ax,ay);ax/=len;ay/=len;
|
||||
const dn=ay<0?'up':ay>0?'down':ax<0?'left':'right';
|
||||
p.facing=dn;p.atkCD=p.mode===MODE_RANGED?10:22;
|
||||
if(p.mode===MODE_RANGED)room.tears.push(new Tear(p.x,p.y,ax,ay));
|
||||
else this.meleeSwing=new MeleeSwing(p.x,p.y,dn);
|
||||
}
|
||||
|
||||
// --- MELEE ---
|
||||
if(this.meleeSwing&&!this.meleeSwing.alive)this.meleeSwing=null;
|
||||
if(this.meleeSwing){
|
||||
this.meleeSwing.life--;
|
||||
for(const e of room.enemies){
|
||||
if(!e.alive||e.hitTimer>0)continue;
|
||||
if(overlap(e.box,this.meleeSwing.box)){
|
||||
e.hp-=this.meleeSwing.damage;
|
||||
e.hitTimer=10;
|
||||
const[dx,dy]=DIR[this.meleeSwing.dir];
|
||||
e.knx=dx*this.meleeSwing.kb;e.kny=dy*this.meleeSwing.kb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- TEARS ---
|
||||
for(const t of room.tears){
|
||||
if(!t.alive)continue;
|
||||
t.x+=t.dx*t.speed;t.y+=t.dy*t.speed;t.life--;
|
||||
const col=Math.floor((t.x-OX)/TILE),row=Math.floor((t.y-OY)/TILE);
|
||||
if(col<0||col>=COLS||row<0||row>=ROWS||t.life<=0){t.life=0;continue}
|
||||
if(room.tiles[row][col]===T_WALL){t.life=0;continue}
|
||||
for(const e of room.enemies){
|
||||
if(!e.alive)continue;
|
||||
if(dist(t.x,t.y,e.x,e.y)<e.w/2+t.r){e.hp-=t.damage;e.hitTimer=8;t.life=0;break}
|
||||
}
|
||||
}
|
||||
room.tears=room.tears.filter(t=>t.alive);
|
||||
|
||||
// --- ENEMY AI ---
|
||||
let aliveCount=0;
|
||||
for(const e of room.enemies){
|
||||
if(!e.alive)continue;
|
||||
aliveCount++;
|
||||
if(e.hitTimer>0)e.hitTimer--;
|
||||
if(Math.abs(e.knx)>0.1||Math.abs(e.kny)>0.1){
|
||||
e.x+=e.knx*3;e.y+=e.kny*3;e.knx*=0.85;e.kny*=0.85;
|
||||
continue;
|
||||
}
|
||||
e.knx=0;e.kny=0;
|
||||
const dx=p.x-e.x,dy=p.y-e.y,d=Math.hypot(dx,dy);
|
||||
if(d>0&&d<500){
|
||||
const s=e.speed,mx=dx/d*s,my=dy/d*s;
|
||||
e.x+=mx;if(this.collidesWall(e,room))e.x-=mx;
|
||||
e.y+=my;if(this.collidesWall(e,room))e.y-=my;
|
||||
}
|
||||
if(e.atkTimer>0)e.atkTimer--;
|
||||
if(dist(e.x,e.y,p.x,p.y)<(e.w+p.w)/2&&p.invTimer<=0&&e.atkTimer<=0){
|
||||
p.hp-=e.damage;p.invTimer=60;e.atkTimer=30;
|
||||
if(p.hp<=0){GAME_OVER=true;return}
|
||||
}
|
||||
}
|
||||
|
||||
// --- ROOM CLEARED ---
|
||||
if(room.enemies.length>0&&aliveCount===0&&!room.cleared){
|
||||
room.cleared=true;room.buildTiles();
|
||||
}
|
||||
|
||||
// --- TRANSITION ---
|
||||
this.checkTransition();
|
||||
|
||||
// --- WIN ---
|
||||
if(!GAME_OVER){
|
||||
const br=Object.values(this.map.rooms).find(r=>r.type==='boss');
|
||||
if(br&&br.cleared)WON=true;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// RENDER
|
||||
// ============================================================
|
||||
render(){
|
||||
const ctx=this.ctx;
|
||||
ctx.fillStyle='#0a0a0f';
|
||||
ctx.fillRect(0,0,CW,CH);
|
||||
this.drawRoom();
|
||||
this.drawEntities();
|
||||
this.drawHUD();
|
||||
this.drawMinimap();
|
||||
if(GAME_OVER)this.drawOverlay('#c33','GAME OVER');
|
||||
else if(WON)this.drawOverlay('#3c3','VICTORY');
|
||||
}
|
||||
|
||||
drawOverlay(c,t){
|
||||
const ctx=this.ctx;
|
||||
ctx.fillStyle='rgba(0,0,0,0.8)';ctx.fillRect(0,0,CW,CH);
|
||||
ctx.fillStyle=c;ctx.font='bold 56px monospace';ctx.textAlign='center';ctx.fillText(t,CW/2,CH/2-20);
|
||||
ctx.fillStyle='#888';ctx.font='18px monospace';ctx.fillText('[R] restart',CW/2,CH/2+40);
|
||||
}
|
||||
|
||||
drawRoom(){
|
||||
const ctx=this.ctx,room=this.cur;
|
||||
// floor
|
||||
for(let r=0;r<ROWS;r++)for(let c=0;c<COLS;c++){
|
||||
const x=OX+c*TILE,y=OY+r*TILE,t=room.tiles[r][c];
|
||||
if(t===T_WALL){
|
||||
ctx.fillStyle='#1a1a24';ctx.fillRect(x,y,TILE,TILE);
|
||||
ctx.fillStyle='#242436';ctx.fillRect(x+2,y+2,TILE-4,TILE-4);
|
||||
ctx.fillStyle='#1e1e2c';ctx.fillRect(x+4,y+4,TILE-8,TILE-8);
|
||||
// brick lines
|
||||
ctx.strokeStyle='#161620';ctx.lineWidth=1;
|
||||
ctx.beginPath();ctx.moveTo(x,y+TILE/2);ctx.lineTo(x+TILE,y+TILE/2);ctx.stroke();
|
||||
ctx.beginPath();ctx.moveTo(x+TILE/2,y);ctx.lineTo(x+TILE/2,y+TILE/2);ctx.stroke();
|
||||
}else if(t===T_DOOR){
|
||||
ctx.fillStyle='#0d0d14';ctx.fillRect(x,y,TILE,TILE);
|
||||
ctx.fillStyle='#2a1e0e';ctx.fillRect(x+6,y+6,TILE-12,TILE-12);
|
||||
ctx.fillStyle='#3a2e14';ctx.fillRect(x+10,y+10,TILE-20,TILE-20);
|
||||
}else{
|
||||
const d=(r+c)%2===0;
|
||||
ctx.fillStyle=d?'#2e2e24':'#353528';
|
||||
ctx.fillRect(x,y,TILE,TILE);
|
||||
}
|
||||
}
|
||||
// wall overlay gradient at edges
|
||||
const grad=ctx.createLinearGradient(OX,OY,OX+RW,OY);
|
||||
ctx.strokeStyle='rgba(0,0,0,0.3)';ctx.lineWidth=2;
|
||||
ctx.strokeRect(OX,OY,RW,RH);
|
||||
}
|
||||
|
||||
drawEntities(){
|
||||
const ctx=this.ctx,room=this.cur,p=this.player;
|
||||
|
||||
// --- ENEMIES ---
|
||||
for(const e of room.enemies){
|
||||
if(!e.alive)continue;
|
||||
const fl=e.hitTimer>0&&e.hitTimer%4<2;
|
||||
ctx.save();
|
||||
// shadow
|
||||
ctx.fillStyle='rgba(0,0,0,0.3)';ctx.beginPath();ctx.ellipse(e.x+2,e.y+e.h/4,e.w/3,4,0,0,Math.PI*2);ctx.fill();
|
||||
|
||||
if(e.type==='boss'){
|
||||
ctx.fillStyle=fl?'#ddd':'#5a0a0a';
|
||||
ctx.beginPath();ctx.arc(e.x,e.y,e.w/2,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#4a0808';ctx.beginPath();ctx.arc(e.x-3,e.y-3,e.w/2-4,0,Math.PI*2);ctx.fill();
|
||||
// eyes
|
||||
ctx.fillStyle=fl?'#000':'#ff3333';
|
||||
ctx.beginPath();ctx.arc(e.x-8,e.y-8,5,0,Math.PI*2);ctx.fill();
|
||||
ctx.beginPath();ctx.arc(e.x+8,e.y-8,5,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#000';ctx.beginPath();ctx.arc(e.x-8,e.y-8,2.5,0,Math.PI*2);ctx.fill();
|
||||
ctx.beginPath();ctx.arc(e.x+8,e.y-8,2.5,0,Math.PI*2);ctx.fill();
|
||||
// horns
|
||||
ctx.fillStyle=fl?'#bbb':'#3a0505';
|
||||
ctx.beginPath();ctx.moveTo(e.x-16,e.y-e.w/2+4);ctx.lineTo(e.x-8,e.y-e.w/2-16);ctx.lineTo(e.x,e.y-e.w/2+4);ctx.fill();
|
||||
ctx.beginPath();ctx.moveTo(e.x-4,e.y-e.w/2+4);ctx.lineTo(e.x+4,e.y-e.w/2-16);ctx.lineTo(e.x+12,e.y-e.w/2+4);ctx.fill();
|
||||
// HP
|
||||
if(e.hp<e.maxHp){
|
||||
ctx.fillStyle='#222';ctx.fillRect(e.x-22,e.y-e.h/2-14,44,4);
|
||||
ctx.fillStyle='#c33';ctx.fillRect(e.x-22,e.y-e.h/2-14,44*(e.hp/e.maxHp),4);
|
||||
}
|
||||
}else if(e.type==='fast'){
|
||||
ctx.fillStyle=fl?'#ddd':'#992222';
|
||||
ctx.beginPath();ctx.arc(e.x,e.y,e.w/2,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#771111';ctx.beginPath();ctx.arc(e.x-1,e.y-1,e.w/2-3,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#ff4444';
|
||||
ctx.beginPath();ctx.arc(e.x-5,e.y-4,3,0,Math.PI*2);ctx.fill();
|
||||
ctx.beginPath();ctx.arc(e.x+5,e.y-4,3,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#000';ctx.beginPath();ctx.arc(e.x-5,e.y-5,1.5,0,Math.PI*2);ctx.fill();
|
||||
ctx.beginPath();ctx.arc(e.x+5,e.y-5,1.5,0,Math.PI*2);ctx.fill();
|
||||
}else{
|
||||
ctx.fillStyle=fl?'#ccc':'#5a4a2e';
|
||||
ctx.fillRect(e.x-e.w/2,e.y-e.h/2,e.w,e.h);
|
||||
ctx.fillStyle='#4a3a1e';ctx.fillRect(e.x-e.w/2+3,e.y-e.h/2+3,e.w-6,e.h-6);
|
||||
ctx.fillStyle='#332816';ctx.fillRect(e.x-e.w/2+6,e.y-e.h/2+6,e.w-12,e.h-12);
|
||||
ctx.fillStyle='#ffcc66';
|
||||
ctx.fillRect(e.x-7,e.y-5,5,5);ctx.fillRect(e.x+2,e.y-5,5,5);
|
||||
ctx.fillStyle='#000';ctx.fillRect(e.x-6,e.y-4,3,3);ctx.fillRect(e.x+3,e.y-4,3,3);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// --- PLAYER ---
|
||||
ctx.save();
|
||||
const ifl=p.invTimer>0&&p.invTimer%6<3;
|
||||
const pCol=p.mode===MODE_RANGED?'#2a6a9a':'#9a3a2a';
|
||||
ctx.fillStyle=ifl?'#ddd':pCol;
|
||||
// body shape
|
||||
ctx.fillRect(p.x-p.w/2,p.y-p.h/2,p.w,p.h);
|
||||
ctx.fillStyle=ifl?'#ccc':'rgba(0,0,0,0.3)';
|
||||
ctx.fillRect(p.x-p.w/2+3,p.y-p.h/2+3,p.w-6,p.h-6);
|
||||
|
||||
// weapon
|
||||
const[fx,fy]=DIR[p.facing];
|
||||
const wx=p.x+fx*(p.w/2+4),wy=p.y+fy*(p.h/2+4);
|
||||
|
||||
if(p.mode===MODE_RANGED){
|
||||
// PISTOL
|
||||
ctx.strokeStyle=ifl?'#999':'#555';
|
||||
ctx.lineWidth=3;ctx.lineCap='round';
|
||||
// barrel
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(wx,wy);
|
||||
ctx.lineTo(wx+fx*14+fy*2,wy+fy*14+fx*2);
|
||||
ctx.stroke();
|
||||
// body
|
||||
ctx.fillStyle=ifl?'#aaa':'#444';
|
||||
const pw=14,ph=8;
|
||||
ctx.save();
|
||||
const rot=fy!==0?Math.PI/2*(fy<0?-1:1):fx<0?Math.PI:0;
|
||||
ctx.translate(p.x+fx*8,p.y+fy*8);
|
||||
ctx.rect(-pw/2,-ph/2,pw,ph);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
// muzzle flash on attack
|
||||
if(p.atkCD>8&&p.mode===MODE_RANGED){
|
||||
ctx.fillStyle='rgba(255,200,50,0.6)';
|
||||
ctx.beginPath();ctx.arc(wx+fx*16,wy+fy*16,6,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='rgba(255,255,200,0.4)';
|
||||
ctx.beginPath();ctx.arc(wx+fx*18,wy+fy*18,8,0,Math.PI*2);ctx.fill();
|
||||
}
|
||||
}else{
|
||||
// KNIFE
|
||||
ctx.strokeStyle=ifl?'#bbb':'#ccc';
|
||||
ctx.lineWidth=2;
|
||||
// blade
|
||||
ctx.beginPath();
|
||||
const kx=wx+fx*6,ky=wy+fy*6;
|
||||
ctx.moveTo(kx,ky);
|
||||
ctx.lineTo(kx+fx*16-fy*6,ky+fy*16+fx*6);
|
||||
ctx.lineTo(kx+fx*16+fy*6,ky+fy*16-fx*6);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle=ifl?'#ddd':'#d4d4d4';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle='#999';ctx.stroke();
|
||||
// handle
|
||||
ctx.fillStyle=ifl?'#a99':'#5a3a1a';
|
||||
ctx.fillRect(kx-fx*3-fy*3,ky-fy*3-fx*3,8,8);
|
||||
// guard
|
||||
ctx.fillStyle=ifl?'#bbb':'#888';
|
||||
ctx.fillRect(kx-fx*2-fy*5,ky-fy*2-fx*5,5,12);
|
||||
}
|
||||
|
||||
// eyes
|
||||
ctx.fillStyle='#fff';
|
||||
const ex=p.x+fx*5,ey=p.y+fy*5;
|
||||
ctx.fillRect(ex-5,ey-4,4,5);ctx.fillRect(ex+1,ey-4,4,5);
|
||||
ctx.fillStyle='#111';
|
||||
ctx.fillRect(ex-4+fx,ey-3+fy,2,3);ctx.fillRect(ex+2+fx,ey-3+fy,2,3);
|
||||
ctx.restore();
|
||||
|
||||
// --- TEARS ---
|
||||
for(const t of room.tears){
|
||||
if(!t.alive)continue;
|
||||
ctx.save();
|
||||
ctx.fillStyle='#6699cc';ctx.beginPath();ctx.arc(t.x,t.y,t.r,0,Math.PI*2);ctx.fill();
|
||||
ctx.fillStyle='#99bbee';ctx.beginPath();ctx.arc(t.x-1.5,t.y-1.5,t.r-2,0,Math.PI*2);ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// --- MELEE SWING ---
|
||||
if(this.meleeSwing&&this.meleeSwing.alive){
|
||||
const s=this.meleeSwing,a=s.life/10;
|
||||
ctx.save();
|
||||
ctx.globalAlpha=a*0.35;
|
||||
ctx.fillStyle='#cc8844';ctx.fillRect(s.box.x,s.box.y,s.box.w,s.box.h);
|
||||
ctx.globalAlpha=a;
|
||||
ctx.strokeStyle='#ddbb88';ctx.lineWidth=2;ctx.strokeRect(s.box.x,s.box.y,s.box.w,s.box.h);
|
||||
ctx.globalAlpha=a*0.8;
|
||||
ctx.strokeStyle='#ffcc88';ctx.lineWidth=3;
|
||||
const[dx,dy]=DIR[s.dir];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(s.box.x+s.box.w/2-dx*18,s.box.y+s.box.h/2-dy*18);
|
||||
ctx.lineTo(s.box.x+s.box.w/2+dx*18,s.box.y+s.box.h/2+dy*18);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
drawHUD(){
|
||||
const ctx=this.ctx,p=this.player;
|
||||
|
||||
// --- HP BAR ---
|
||||
const bx=20,by=20,bw=140,bh=14;
|
||||
ctx.fillStyle='#111';ctx.fillRect(bx,by,bw,bh);
|
||||
ctx.fillStyle='#2a0a0a';ctx.fillRect(bx+2,by+2,bw-4,bh-4);
|
||||
const hpRatio=Math.max(0,p.hp/p.maxHp);
|
||||
const hpCol=hpRatio>0.5?'#993333':hpRatio>0.25?'#994422':'#663322';
|
||||
ctx.fillStyle=hpCol;ctx.fillRect(bx+2,by+2,(bw-4)*hpRatio,bh-4);
|
||||
ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.strokeRect(bx,by,bw,bh);
|
||||
ctx.fillStyle='#bbb';ctx.font='10px monospace';ctx.textAlign='center';
|
||||
ctx.fillText(`HP ${p.hp}/${p.maxHp}`,bx+bw/2,by+bh-3);
|
||||
|
||||
// --- MODE INDICATOR ---
|
||||
const my=CH-46;
|
||||
ctx.textAlign='center';
|
||||
const mText=p.mode===MODE_RANGED?'RANGED':'MELEE';
|
||||
const mCol=p.mode===MODE_RANGED?'#4488cc':'#cc6644';
|
||||
ctx.fillStyle='#0d0d0d';ctx.fillRect(CW/2-95,my-18,190,34);
|
||||
ctx.strokeStyle=mCol;ctx.lineWidth=2;ctx.strokeRect(CW/2-95,my-18,190,34);
|
||||
ctx.fillStyle=mCol;ctx.font='bold 17px monospace';ctx.fillText(`[ ${mText} ]`,CW/2,my+8);
|
||||
ctx.fillStyle='#555';ctx.font='11px monospace';ctx.fillText('[Tab] switch',CW/2,my-26);
|
||||
|
||||
// weapon icon in mode indicator
|
||||
if(p.mode===MODE_RANGED){
|
||||
ctx.strokeStyle='#88bbdd';ctx.lineWidth=2;
|
||||
ctx.beginPath();ctx.moveTo(CW/2-82,my-4);ctx.lineTo(CW/2-72,my-4);ctx.stroke();
|
||||
ctx.fillStyle='#88bbdd';ctx.fillRect(CW/2-82,my-8,10,8);
|
||||
}else{
|
||||
ctx.fillStyle='#ddbb88';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(CW/2-82,my-10);ctx.lineTo(CW/2-74,my-2);ctx.lineTo(CW/2-82,my+4);ctx.fill();
|
||||
}
|
||||
|
||||
// --- ENEMY COUNT ---
|
||||
ctx.textAlign='left';
|
||||
const room=this.cur;
|
||||
const alive=room.enemies.filter(e=>e.alive).length;
|
||||
if(alive>0){
|
||||
ctx.fillStyle='#aa4444';ctx.font='13px monospace';ctx.fillText(`\u25B6 ${alive}`,20,CH-18);
|
||||
}else if(!room.cleared&&room.type!=='spawn'){
|
||||
ctx.fillStyle='#886633';ctx.font='13px monospace';ctx.fillText('Clear the room',20,CH-18);
|
||||
}
|
||||
|
||||
// --- ROOM TYPE ---
|
||||
if(room.visited){
|
||||
ctx.textAlign='right';
|
||||
const tn={spawn:'START',normal:'',treasure:'TREASURE',boss:'BOSS'}[room.type];
|
||||
if(tn){
|
||||
ctx.fillStyle='#555';ctx.font='11px monospace';ctx.fillText(tn,CW-20,OY+RH+30);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawMinimap(){
|
||||
const ctx=this.ctx;
|
||||
const mx=CW-180,my=12,cell=14,gap=2,cs=cell+gap;
|
||||
|
||||
ctx.fillStyle='rgba(0,0,0,0.75)';ctx.fillRect(mx-8,my-8,cs*7+16,cs*7+16);
|
||||
ctx.strokeStyle='#333';ctx.lineWidth=1;
|
||||
ctx.strokeRect(mx-8,my-8,cs*7+16,cs*7+16);
|
||||
|
||||
for(let r=-3;r<=3;r++)for(let c=-3;c<=3;c++){
|
||||
const room=this.map.get(this.cc+c,this.cr+r);
|
||||
if(!room)continue;
|
||||
const x=mx+(c+3)*cs,y=my+(r+3)*cs;
|
||||
let color='#141414';
|
||||
if(room.visited){
|
||||
const cl={spawn:'#2a5a2a',boss:'#5a1a1a',treasure:'#5a5a1a'}[room.type]||'#555';
|
||||
color=cl;
|
||||
}
|
||||
ctx.fillStyle=color;ctx.fillRect(x,y,cell,cell);
|
||||
if(room.visited){
|
||||
ctx.strokeStyle='rgba(255,255,255,0.12)';ctx.lineWidth=1;
|
||||
if(room.doors.up)ctx.fillRect(x+cs/2-2,y-2,4,3);
|
||||
if(room.doors.down)ctx.fillRect(x+cs/2-2,y+cell-1,4,3);
|
||||
if(room.doors.left)ctx.fillRect(x-2,y+cs/2-2,3,4);
|
||||
if(room.doors.right)ctx.fillRect(x+cell-1,y+cs/2-2,3,4);
|
||||
}
|
||||
if(c===0&&r===0){
|
||||
ctx.strokeStyle='#ddd';ctx.lineWidth=2;ctx.strokeRect(x-1.5,y-1.5,cell+3,cell+3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// START
|
||||
// ============================================================
|
||||
window.addEventListener('load',()=>{game=new Game()});
|
||||
</script>
|
||||
<!-- Стартовое меню. Кнопки уровней генерирует StartMenu из PRESETS. -->
|
||||
<div id="menu">
|
||||
<h1><img id="menu-logo" src="assets/logo.png" alt="Биндим Фигняшку" /></h1>
|
||||
<p class="menu-sub">Выбери уровень</p>
|
||||
<div id="menu-presets"></div>
|
||||
<p class="menu-hint">WASD — движение · Стрелки — стрельба · Tab — оружие · Esc — меню</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
"scripts": {
|
||||
"dev": "bun run dev.ts",
|
||||
"build": "bun run build.ts",
|
||||
"start": "bun run build && echo 'open dist/index.html'"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test",
|
||||
"check": "bun run typecheck && bun test",
|
||||
"start": "bun run build && echo 'Готово — открой dist/index.html'"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/three": "^0.184.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
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 |
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* config.ts — ВСЕ настройки игры в одном месте.
|
||||
*
|
||||
* Здесь сосредоточены: размеры мира, геометрия комнат/дверей и баланс
|
||||
* (скорости, здоровье, перезарядки). Меняй числа тут, чтобы «крутить»
|
||||
* игру — больше ничего трогать не нужно.
|
||||
*
|
||||
* ВАЖНО: единица времени — один «шаг» симуляции (= 1/60 секунды), а НЕ
|
||||
* кадр браузера. Цикл игры (см. engine/GameLoop.ts) гарантирует ровно
|
||||
* 60 шагов в секунду на любом мониторе, поэтому скорости заданы «пикселей
|
||||
* за шаг», а перезарядки — «в шагах».
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Холст и сетка
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const CW = 880; // ширина области отрисовки (px)
|
||||
export const CH = 660; // высота области отрисовки (px)
|
||||
export const TILE = 44; // размер одного тайла (px)
|
||||
export const COLS = 15; // тайлов по горизонтали в комнате
|
||||
export const ROWS = 11; // тайлов по вертикали в комнате
|
||||
export const RW = COLS * TILE; // ширина комнаты в пикселях
|
||||
export const RH = ROWS * TILE; // высота комнаты в пикселях
|
||||
export const OX = (CW - RW) / 2; // отступ комнаты слева
|
||||
export const OY = 80; // отступ комнаты сверху (место под HUD)
|
||||
|
||||
// Частота симуляции. Логика всегда обновляется с этим шагом.
|
||||
export const FIXED_FPS = 60;
|
||||
export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг
|
||||
|
||||
export const GAME_LOOP = {
|
||||
maxFrameTimeSec: 0.25, // не «отыгрывать» долгие паузы (фон/таб)
|
||||
maxStepsPerFrame: 5, // защита от «спирали смерти» при лагах
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Типы тайлов
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const T_WALL = 0;
|
||||
export const T_FLOOR = 1;
|
||||
export const T_DOOR = 2;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Режимы боя
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const MODE_RANGED = 0;
|
||||
export const MODE_MELEE = 1;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Направления
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
import type { Dir } from './core/types';
|
||||
|
||||
/** Единичный вектор смещения для каждого направления (x вправо, y вниз). */
|
||||
export const DIR: Record<Dir, readonly [number, number]> = {
|
||||
up: [0, -1],
|
||||
down: [0, 1],
|
||||
left: [-1, 0],
|
||||
right: [1, 0],
|
||||
};
|
||||
|
||||
/** Противоположное направление. Используется при простановке дверей соседей. */
|
||||
export const OPP: Record<Dir, Dir> = {
|
||||
up: 'down',
|
||||
down: 'up',
|
||||
left: 'right',
|
||||
right: 'left',
|
||||
};
|
||||
|
||||
/**
|
||||
* Геометрия дверей: проём шириной в 3 тайла по центру каждой стороны.
|
||||
* cx/cy — координата тайла-центра двери (для спавна игрока и расчётов).
|
||||
*/
|
||||
export const DOOR = {
|
||||
up: { cols: [6, 7, 8] as number[], row: 0, cx: 7, cy: 0 },
|
||||
down: { cols: [6, 7, 8] as number[], row: ROWS - 1, cx: 7, cy: ROWS - 1 },
|
||||
left: { col: 0, rows: [4, 5, 6] as number[], cx: 0, cy: 5 },
|
||||
right: { col: COLS - 1, rows: [4, 5, 6] as number[], cx: COLS - 1, cy: 5 },
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Генерация карты (случайное блуждание)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const MAP_RADIUS = 3; // карта вмещается в сетку (2*R+1)²
|
||||
export const MIN_ROOMS = 8; // минимум комнат
|
||||
export const EXTRA_ROOMS = 4; // + случайно до этого числа
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Баланс: игрок
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const PLAYER = {
|
||||
size: 26,
|
||||
speed: 3.2, // px за шаг
|
||||
maxHp: 6,
|
||||
invFrames: 60, // неуязвимость после удара, в шагах
|
||||
entryInvFrames: 20, // короткая неуязвимость при входе в комнату, в шагах
|
||||
rangedCooldown: 10, // перезарядка выстрела, в шагах
|
||||
meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
|
||||
transitionLock: 15, // блок повторного перехода между комнатами, в шагах
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Баланс: снаряд (слеза) и ближний бой
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const PROJECTILE = {
|
||||
radius: 5,
|
||||
speed: 7, // px за шаг
|
||||
damage: 1,
|
||||
life: 80, // время жизни в шагах
|
||||
};
|
||||
|
||||
export const MELEE = {
|
||||
reach: 22, // отступ хитбокса от центра игрока
|
||||
size: 50, // сторона квадратного хитбокса
|
||||
life: 10, // длительность взмаха в шагах
|
||||
damage: 2,
|
||||
knockback: 10,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Баланс: враги (таблица характеристик по типу)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const ENEMY_STATS = {
|
||||
normal: { size: 32, hp: 3, speed: 1.15, damage: 1 },
|
||||
fast: { size: 26, hp: 2, speed: 1.9, damage: 1 },
|
||||
boss: { size: 46, hp: 10, speed: 0.9, damage: 2 },
|
||||
charger: { size: 32, hp: 5, speed: 1.6, damage: 1 },
|
||||
tank: { size: 44, hp: 10, speed: 0.6, damage: 2 },
|
||||
shooter: { size: 28, hp: 3, speed: 1.0, damage: 1 },
|
||||
splitter: { size: 34, hp: 4, speed: 1.1, damage: 1 },
|
||||
} as const;
|
||||
|
||||
export const ENEMY = {
|
||||
aggroRange: 500, // дистанция, с которой враг начинает преследование
|
||||
attackCooldown: 30, // пауза между контактными ударами, в шагах
|
||||
hitFlash: 8, // длительность «мигания» при попадании, в шагах
|
||||
knockbackDecay: 0.85,
|
||||
fastChance: 0.3, // доля быстрых врагов в обычной комнате
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Спавн врагов
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const SPAWN = {
|
||||
normalMin: 2, // минимум врагов в обычной комнате
|
||||
normalExtra: 2, // + случайно до этого числа
|
||||
minDistFromDoor: 180, // не спавнить ближе к двери входа
|
||||
minDistFromPlayer: 150,
|
||||
minDistBetween: 60,
|
||||
maxPlacementTries: 100,
|
||||
treasureChance: 0.12, // шанс комнаты-сокровищницы
|
||||
bossChance: 0.2, // шанс назначить комнату боссом
|
||||
secretChance: 0.08, // шанс секретной комнаты (+1 max HP один раз при входе)
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Сундук (сокровищница)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const CHEST = {
|
||||
size: 40,
|
||||
hp: 5,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Масштабирование этажей (New Game+)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const FLOOR_SCALING = {
|
||||
roomsPerFloor: 2, // + комнат на этаж
|
||||
hpMulPerFloor: 0.15, // + множитель HP врагов на этаж
|
||||
speedMulPerFloor: 0.05, // + множитель скорости на этаж
|
||||
densityMulPerFloor: 0.1, // + множитель плотности на этаж
|
||||
bossHpMulPerFloor: 0.2, // + множитель HP босса на этаж
|
||||
fastChancePerFloor: 0.03, // + доля быстрых врагов на этаж
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Боссы (мультифазные, milestone-этажи)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
export const BOSS = {
|
||||
maxMinions: 4, // верхний лимит живых миньёнов одного босса — иначе комната может не зачиститься
|
||||
minionInterval: 120, // шагов между попытками спавна миньёнов (фаза 3)
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
// Canvas & grid dimensions
|
||||
export const CW = 880;
|
||||
export const CH = 660;
|
||||
export const TILE = 44;
|
||||
export const COLS = 15;
|
||||
export const ROWS = 11;
|
||||
export const RW = COLS * TILE;
|
||||
export const RH = ROWS * TILE;
|
||||
export const OX = (CW - RW) / 2;
|
||||
export const OY = 80;
|
||||
|
||||
// Tile types
|
||||
export const T_WALL = 0;
|
||||
export const T_FLOOR = 1;
|
||||
export const T_DOOR = 2;
|
||||
|
||||
// Combat modes
|
||||
export const MODE_RANGED = 0;
|
||||
export const MODE_MELEE = 1;
|
||||
|
||||
// Direction vectors
|
||||
export const DIR: Record<string, [number, number]> = {
|
||||
up: [0, -1],
|
||||
down: [0, 1],
|
||||
left: [-1, 0],
|
||||
right: [1, 0],
|
||||
};
|
||||
|
||||
// Opposite direction lookup
|
||||
export const OPP: Record<string, string> = {
|
||||
up: 'bottom',
|
||||
down: 'top',
|
||||
left: 'right',
|
||||
right: 'left',
|
||||
};
|
||||
|
||||
// Door opening geometry (3 tiles wide at each cardinal edge)
|
||||
export const DOOR = {
|
||||
up: { cols: [6, 7, 8], row: 0, cx: 7, cy: 0 },
|
||||
down: { cols: [6, 7, 8], row: 10, cx: 7, cy: 10 },
|
||||
left: { col: 0, rows: [4, 5, 6], cx: 0, cy: 5 },
|
||||
right: { col: 14, rows: [4, 5, 6], cx: 14, cy: 5 },
|
||||
} as const;
|
||||
|
||||
// Grid generation bounds
|
||||
export const MAP_RADIUS = 3;
|
||||
export const MIN_ROOMS = 8;
|
||||
export const EXTRA_ROOMS = 4;
|
||||
@@ -0,0 +1,563 @@
|
||||
import {
|
||||
DIR, DOOR, OX, OY, TILE, COLS, ROWS,
|
||||
MODE_RANGED, MODE_MELEE, PLAYER, ENEMY, MELEE, PROJECTILE,
|
||||
} from '../config';
|
||||
import type { Dir } from './types';
|
||||
import { Rng } from './rng';
|
||||
import { dist, overlap } from './util';
|
||||
import { Player } from './entities/Player';
|
||||
import { Enemy } from './entities/Enemy';
|
||||
import { Projectile } from './entities/Projectile';
|
||||
import { MeleeSwing } from './entities/MeleeSwing';
|
||||
import { RoomMap } from './world/RoomMap';
|
||||
import type { Room } from './world/Room';
|
||||
import { collidesWall } from './systems/collision';
|
||||
import { moveEntity } from './systems/movement';
|
||||
import { runAI, spawnSplitterChildren, type AIContext } from './systems/ai';
|
||||
import {
|
||||
applyWeaponProjectileStats, explodeBomb, projectileHitWall,
|
||||
} from './systems/projectiles';
|
||||
import { spawnEnemies, spawnChest, pickChestWeapon } from './systems/spawner';
|
||||
import { Pickup } from './entities/Pickup';
|
||||
import { ITEMS, applyItem, ALL_ITEM_IDS, type ItemId } from './items';
|
||||
import { DEFAULT_RULES, scaleRulesForFloor, type LevelRules } from './rules';
|
||||
import type { InputState } from '../input/InputState';
|
||||
import { pressingDir, cardinalFromVec } from '../input/InputState';
|
||||
|
||||
/**
|
||||
* Game — «мозг» игры. Полностью независим от рендера и DOM: ничего не
|
||||
* рисует и не знает про three.js/canvas. Хранит всё изменяемое состояние
|
||||
* и продвигает симуляцию ровно на один фиксированный шаг в step().
|
||||
*
|
||||
* Контракт с внешним миром:
|
||||
* • consumeActions(input) — один раз за кадр: смена оружия, рестарт;
|
||||
* • step(input) — один фиксированный шаг физики/логики;
|
||||
* • публичные геттеры/поля — читает рендер.
|
||||
*/
|
||||
export class Game {
|
||||
readonly rules: LevelRules;
|
||||
private floorRules: LevelRules;
|
||||
rng: Rng; // пересоздаётся в reset() — для воспроизводимости фикс-сида
|
||||
roomMap: RoomMap;
|
||||
player: Player;
|
||||
cc = 0; // координаты текущей комнаты на карте
|
||||
cr = 0;
|
||||
meleeSwing: MeleeSwing | null = null;
|
||||
gameOver = false;
|
||||
won = false;
|
||||
floor = 1;
|
||||
inventoryOpen = false;
|
||||
elapsedSteps = 0;
|
||||
|
||||
/**
|
||||
* @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт».
|
||||
* @param rng опционально свой ГПСЧ; иначе берётся seed из правил (или случайный).
|
||||
*/
|
||||
constructor(rules: LevelRules = DEFAULT_RULES, rng?: Rng) {
|
||||
this.rules = rules;
|
||||
this.floorRules = rules;
|
||||
this.rng = rng ?? new Rng(rules.seed);
|
||||
this.player = new Player(rules.player);
|
||||
this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
|
||||
this.roomMap = new RoomMap(this.rng, this.floorRules);
|
||||
this.enterRoom('up');
|
||||
}
|
||||
|
||||
/** Текущая комната (всегда существует: карта связна и переходы — только в имеющиеся комнаты). */
|
||||
get curRoom(): Room {
|
||||
return this.roomMap.get(this.cc, this.cr)!;
|
||||
}
|
||||
|
||||
// ── Публичный контракт цикла ──────────────────────────────
|
||||
|
||||
/** Однократные действия (смена оружия, рестарт, инвентарь). Вызывать раз в кадр. */
|
||||
consumeActions(input: InputState): void {
|
||||
if (input.openInventory && !this.gameOver && !this.won) {
|
||||
this.inventoryOpen = !this.inventoryOpen;
|
||||
}
|
||||
if (this.inventoryOpen) return;
|
||||
if (input.toggleWeapon && !this.gameOver && !this.won) {
|
||||
const p = this.player;
|
||||
p.equipped = p.equipped === 0 ? 1 : 0;
|
||||
p.mode = p.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
|
||||
}
|
||||
if (input.restart && (this.gameOver || this.won)) {
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/** Выбрать слот (из main.ts при открытом инвентаре). Молча игнорирует несуществующие. */
|
||||
equipSlot(slot: number): void {
|
||||
if (slot < 0 || slot >= this.player.weapons.length) return;
|
||||
this.player.equipped = slot as 0 | 1;
|
||||
this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
|
||||
this.inventoryOpen = false;
|
||||
}
|
||||
|
||||
/** Закрыть инвентарь без прямой мутации поля снаружи Game. */
|
||||
closeInventory(): void {
|
||||
this.inventoryOpen = false;
|
||||
}
|
||||
|
||||
/** Один фиксированный шаг симуляции (= 1/60 c). */
|
||||
step(input: InputState): void {
|
||||
if (this.gameOver || this.won || this.inventoryOpen) return;
|
||||
|
||||
this.elapsedSteps++;
|
||||
|
||||
const room = this.curRoom;
|
||||
const p = this.player;
|
||||
|
||||
// Запоминаем позиции для плавной интерполяции при рендере.
|
||||
p.prevX = p.x; p.prevY = p.y;
|
||||
for (const e of room.enemies) { e.prevX = e.x; e.prevY = e.y; }
|
||||
for (const t of room.tears) { t.prevX = t.x; t.prevY = t.y; }
|
||||
|
||||
// Таймеры.
|
||||
if (p.invTimer > 0) p.invTimer--;
|
||||
if (p.atkCD > 0) p.atkCD--;
|
||||
if (p.transCD > 0) p.transCD--;
|
||||
|
||||
this.movePlayer(input, room, p);
|
||||
this.handleAttack(input, room, p);
|
||||
this.updateMelee(room);
|
||||
this.updateTears(room);
|
||||
this.updateChest(room);
|
||||
this.updatePickup(room, p);
|
||||
const aliveCount = this.updateEnemies(room, p);
|
||||
if (this.gameOver) return;
|
||||
|
||||
// Комната зачищена: все враги мертвы (aliveCount === 0 после фильтра
|
||||
// означает, что ни живых, ни свежеспавненных не осталось). Проверку
|
||||
// через room.enemies.length использовать нельзя — после фильтрации длина
|
||||
// уже 0; считаем по живым из updateEnemies.
|
||||
if (aliveCount === 0 && !room.cleared && room.type !== 'spawn' && room.type !== 'treasure' && room.type !== 'secret') {
|
||||
room.cleared = true;
|
||||
room.rebuildTiles();
|
||||
}
|
||||
|
||||
this.checkTransition(input);
|
||||
this.checkWin();
|
||||
}
|
||||
|
||||
/** Полный сброс — новая карта, новый игрок (рестарт после конца игры). */
|
||||
reset(): void {
|
||||
this.gameOver = false;
|
||||
this.won = false;
|
||||
this.floor = 1;
|
||||
this.inventoryOpen = false;
|
||||
this.elapsedSteps = 0;
|
||||
this.floorRules = this.rules;
|
||||
// Пере-сеем ГПСЧ из правил: фикс-сид → тот же данжен, иначе → новый каждый раз.
|
||||
this.rng = new Rng(this.rules.seed);
|
||||
this.roomMap = new RoomMap(this.rng, this.floorRules);
|
||||
this.player = new Player(this.rules.player);
|
||||
this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
|
||||
this.cc = 0;
|
||||
this.cr = 0;
|
||||
this.meleeSwing = null;
|
||||
this.enterRoom('up');
|
||||
}
|
||||
|
||||
// ── Переход между комнатами ───────────────────────────────
|
||||
|
||||
/** Расставляет игрока внутри текущей комнаты у двери fromDir и (при нужде) спавнит врагов. */
|
||||
enterRoom(fromDir: Dir): void {
|
||||
const room = this.curRoom;
|
||||
const wasVisited = room.visited; // ловим «первый вход» до установки флага
|
||||
room.visited = true;
|
||||
|
||||
const d = DOOR[fromDir];
|
||||
const [ddc, ddr] = DIR[fromDir];
|
||||
// Ставим игрока на один тайл внутрь от центра двери.
|
||||
const px = OX + d.cx * TILE + TILE / 2 - ddc * TILE;
|
||||
const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
|
||||
this.player.place(px, py);
|
||||
this.player.facing = fromDir;
|
||||
this.player.invTimer = PLAYER.entryInvFrames;
|
||||
this.player.transCD = PLAYER.transitionLock;
|
||||
|
||||
this.meleeSwing = null;
|
||||
room.tears = [];
|
||||
|
||||
if (!room.cleared && room.type !== 'spawn') {
|
||||
room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.floorRules);
|
||||
// Сундук в сокровищнице.
|
||||
if (room.type === 'treasure' && !room.chest) {
|
||||
room.chest = spawnChest(room, this.rng);
|
||||
}
|
||||
// Секретка: +1 max HP один раз при первом входе (лечит заодно на 1).
|
||||
if (room.type === 'secret' && !wasVisited) {
|
||||
this.player.growMaxHp(1);
|
||||
}
|
||||
// Если врагов нет (напр. сокровищница/секретка) — зачищать нечего,
|
||||
// открываем сразу, иначе двери никогда не появятся и игрок застрянет.
|
||||
if (room.enemies.length === 0) room.cleared = true;
|
||||
room.rebuildTiles();
|
||||
} else {
|
||||
room.cleared = true;
|
||||
room.enemies = [];
|
||||
room.rebuildTiles();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Системы (по одному шагу) ──────────────────────────────
|
||||
|
||||
private movePlayer(input: InputState, room: Room, p: Player): void {
|
||||
let mx = input.moveX;
|
||||
let my = input.moveY;
|
||||
if (mx === 0 && my === 0) return;
|
||||
|
||||
const len = Math.hypot(mx, my);
|
||||
mx /= len;
|
||||
my /= len;
|
||||
|
||||
if (input.moveY < 0) p.moveDir = 'up';
|
||||
else if (input.moveY > 0) p.moveDir = 'down';
|
||||
if (input.moveX < 0) p.moveDir = 'left';
|
||||
else if (input.moveX > 0) p.moveDir = 'right';
|
||||
|
||||
moveEntity(p, mx * p.speed, my * p.speed, room);
|
||||
}
|
||||
|
||||
private handleAttack(input: InputState, room: Room, p: Player): void {
|
||||
// Приоритет: явный прицел (вектор стрелок) → иначе направление движения.
|
||||
let nx = 0, ny = 0;
|
||||
let aim = false;
|
||||
if (input.aimVec) {
|
||||
nx = input.aimVec.x;
|
||||
ny = input.aimVec.y;
|
||||
aim = true;
|
||||
} else if (input.attackHeld) {
|
||||
[nx, ny] = DIR[p.moveDir];
|
||||
aim = true;
|
||||
}
|
||||
|
||||
if (!aim || p.atkCD > 0) return;
|
||||
|
||||
const len = Math.hypot(nx, ny) || 1;
|
||||
nx /= len; ny /= len;
|
||||
// Для рендера и door-логики сохраняем facing как одно из 4 направлений.
|
||||
p.facing = cardinalFromVec({ x: nx, y: ny });
|
||||
|
||||
const w = p.currentWeapon;
|
||||
p.atkCD = p.effectiveCooldown(w);
|
||||
const damage = p.effectiveDamage(w);
|
||||
|
||||
if (w.type === 'ranged') {
|
||||
if (w.projectileType === 'beam') {
|
||||
// Лазерный луч — стационарная зона поражения.
|
||||
const range = w.beamRange ?? 70;
|
||||
const t = new Projectile(p.x + nx * range, p.y + ny * range, 0, 0, 'beam');
|
||||
t.speed = 0;
|
||||
t.life = w.beamLife ?? 10;
|
||||
t.damage = (w.beamTickDmg ?? 2) * p.stats.damageMul;
|
||||
t.beamRadius = w.beamRadius ?? 44;
|
||||
room.tears.push(t);
|
||||
} else if (w.spreadCount && w.spreadCount > 1) {
|
||||
const spread = w.spread ?? 0.15;
|
||||
const perpX = -ny;
|
||||
const perpY = nx;
|
||||
const projectileType = w.projectileType ?? 'tear';
|
||||
for (let i = 0; i < w.spreadCount; i++) {
|
||||
const off = (i - (w.spreadCount - 1) / 2) * spread;
|
||||
const sx = nx + perpX * off;
|
||||
const sy = ny + perpY * off;
|
||||
const sl = Math.hypot(sx, sy) || 1;
|
||||
const t = new Projectile(p.x, p.y, sx / sl, sy / sl, projectileType);
|
||||
applyWeaponProjectileStats(t, w, p);
|
||||
room.tears.push(t);
|
||||
}
|
||||
} else {
|
||||
const t = new Projectile(p.x, p.y, nx, ny, w.projectileType ?? 'tear');
|
||||
applyWeaponProjectileStats(t, w, p);
|
||||
room.tears.push(t);
|
||||
}
|
||||
} else {
|
||||
this.meleeSwing = new MeleeSwing(p.x, p.y, cardinalFromVec({ x: nx, y: ny }), {
|
||||
damage,
|
||||
knockback: w.knockback,
|
||||
life: w.swingLife,
|
||||
sizeMul: w.swingSizeMul,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private updateMelee(room: Room): void {
|
||||
if (this.meleeSwing && !this.meleeSwing.alive) this.meleeSwing = null;
|
||||
if (!this.meleeSwing) return;
|
||||
|
||||
this.meleeSwing.life--;
|
||||
for (const e of room.enemies) {
|
||||
if (!e.alive || e.hitTimer > 0) continue;
|
||||
if (overlap(e.box, this.meleeSwing.box)) {
|
||||
e.hp -= this.meleeSwing.damage;
|
||||
e.hitTimer = MELEE.life;
|
||||
const [dx, dy] = DIR[this.meleeSwing.dir];
|
||||
e.knx = dx * this.meleeSwing.kb;
|
||||
e.kny = dy * this.meleeSwing.kb;
|
||||
}
|
||||
}
|
||||
// Удар по сундуку.
|
||||
if (room.chest?.alive && overlap(room.chest.box, this.meleeSwing.box)) {
|
||||
room.chest.hp -= this.meleeSwing.damage;
|
||||
}
|
||||
}
|
||||
|
||||
private updateTears(room: Room): void {
|
||||
for (const t of room.tears) {
|
||||
if (!t.alive) continue;
|
||||
|
||||
// Лазерный луч: стоит на месте, жжёт врагов каждые 2 тика.
|
||||
if (t.type === 'beam') {
|
||||
t.life--;
|
||||
if (t.life <= 0) continue;
|
||||
if (t.life % 2 === 0) {
|
||||
const radius = t.beamRadius || 44;
|
||||
for (const e of room.enemies) {
|
||||
if (!e.alive) continue;
|
||||
if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + radius) {
|
||||
e.hp -= t.damage;
|
||||
e.hitTimer = ENEMY.hitFlash;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Бумеранг: один раз на половине жизни разворачивается.
|
||||
if (t.type === 'boomerang' && t.life === Math.floor(PROJECTILE.life / 2)) {
|
||||
t.dx = -t.dx;
|
||||
t.dy = -t.dy;
|
||||
}
|
||||
|
||||
t.x += t.dx * t.speed;
|
||||
t.y += t.dy * t.speed;
|
||||
t.life--;
|
||||
|
||||
if (t.life <= 0 || projectileHitWall(t, room)) {
|
||||
explodeBomb(room, t);
|
||||
t.life = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (t.hostile) {
|
||||
// Вражеский снаряд: бьёт игрока.
|
||||
const p = this.player;
|
||||
if (dist(t.x, t.y, p.x, p.y) < p.w / 2 + t.r && p.invTimer <= 0) {
|
||||
p.hp -= t.damage;
|
||||
p.invTimer = PLAYER.invFrames;
|
||||
t.life = 0;
|
||||
if (p.hp <= 0) { p.hp = 0; this.gameOver = true; return; }
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Снаряд игрока: бьёт врагов.
|
||||
for (const e of room.enemies) {
|
||||
if (!e.alive) continue;
|
||||
if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + t.r) {
|
||||
e.hp -= t.damage;
|
||||
e.hitTimer = ENEMY.hitFlash;
|
||||
if (t.type === 'fireball') {
|
||||
e.burnTimer = t.burnDuration;
|
||||
e.burnDamage = t.burnDamage;
|
||||
e.burnInterval = t.burnInterval;
|
||||
}
|
||||
if (t.type !== 'laser') {
|
||||
t.life = 0;
|
||||
explodeBomb(room, t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Попадание в сундук.
|
||||
if (room.chest?.alive && dist(t.x, t.y, room.chest.x, room.chest.y) < room.chest.w / 2 + t.r) {
|
||||
room.chest.hp -= t.damage;
|
||||
if (t.type !== 'laser') {
|
||||
t.life = 0;
|
||||
explodeBomb(room, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
room.tears = room.tears.filter((t) => t.alive);
|
||||
}
|
||||
|
||||
/** Сундук уничтожен — спавним предмет или оружие (50/50). */
|
||||
private updateChest(room: Room): void {
|
||||
if (!room.chest || room.pickup) return;
|
||||
if (room.chest.alive) return;
|
||||
const dropWeapon = this.rng.chance(0.5);
|
||||
if (dropWeapon) {
|
||||
room.pickup = Pickup.weapon(room.chest.x, room.chest.y, pickChestWeapon(this.rng));
|
||||
} else {
|
||||
const itemId = this.rng.pick(ALL_ITEM_IDS);
|
||||
room.pickup = Pickup.item(room.chest.x, room.chest.y, itemId);
|
||||
}
|
||||
room.chest = null;
|
||||
}
|
||||
|
||||
/** Подбор пикапа игроком (оружие → слот, предмет → статы). */
|
||||
private updatePickup(room: Room, p: Player): void {
|
||||
if (!room.pickup) return;
|
||||
if (overlap(p.box, room.pickup.box)) {
|
||||
const pk = room.pickup;
|
||||
if (pk.kind === 'weapon' && pk.weaponId !== undefined) {
|
||||
p.addWeapon(pk.weaponId);
|
||||
} else if (pk.kind === 'item' && pk.itemId !== undefined) {
|
||||
const item: ItemId = pk.itemId;
|
||||
const def = ITEMS[item];
|
||||
applyItem(p.stats, def, (hpBonus) => p.growMaxHp(hpBonus));
|
||||
}
|
||||
room.pickup = null;
|
||||
}
|
||||
}
|
||||
|
||||
private updateEnemies(room: Room, p: Player): number {
|
||||
let aliveCount = 0;
|
||||
const newEnemies: Enemy[] = [];
|
||||
const ctx: AIContext = {
|
||||
room, player: p, rng: this.rng, floor: this.floor, floorRules: this.floorRules, newEnemies,
|
||||
};
|
||||
|
||||
// Запоминаем splitter'ов, которые умерли в этом шаге — после цикла спавним
|
||||
// их детей. (Делаем это в конце, чтобы не мутировать массив во время итерации.)
|
||||
const deadSplitters: Enemy[] = [];
|
||||
|
||||
for (const e of room.enemies) {
|
||||
// Мёртвый враг: ловим splitter для распада, иначе пропускаем.
|
||||
if (!e.alive) {
|
||||
if (e.type === 'splitter') deadSplitters.push(e);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (e.hitTimer > 0) e.hitTimer--;
|
||||
|
||||
// Горение: урон каждые fireInterval тиков.
|
||||
if (e.burnTimer > 0) {
|
||||
e.burnTimer--;
|
||||
if (e.burnTimer % e.burnInterval === 0) {
|
||||
e.hp -= e.burnDamage;
|
||||
e.hitTimer = ENEMY.hitFlash;
|
||||
}
|
||||
}
|
||||
if (!e.alive) {
|
||||
// Умер от горения/прошлого удара в этом шаге.
|
||||
if (e.type === 'splitter') deadSplitters.push(e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Фаза отбрасывания: летит по инерции, ИИ не работает.
|
||||
if (Math.abs(e.knx) > 0.1 || Math.abs(e.kny) > 0.1) {
|
||||
e.x += e.knx * 3;
|
||||
if (collidesWall(e.box, room)) e.x -= e.knx * 3;
|
||||
e.y += e.kny * 3;
|
||||
if (collidesWall(e.box, room)) e.y -= e.kny * 3;
|
||||
e.knx *= ENEMY.knockbackDecay;
|
||||
e.kny *= ENEMY.knockbackDecay;
|
||||
aliveCount++;
|
||||
if (e.atkTimer > 0) e.atkTimer--;
|
||||
continue;
|
||||
}
|
||||
e.knx = 0;
|
||||
e.kny = 0;
|
||||
|
||||
aliveCount++;
|
||||
|
||||
runAI(e, ctx);
|
||||
|
||||
// Если за этот шаг враг умер от ИИ-фазы или снаряда (маловероятно,
|
||||
// но возможно при касании уже летящего), отслеживаем.
|
||||
if (!e.alive && e.type === 'splitter') deadSplitters.push(e);
|
||||
if (!e.alive) continue;
|
||||
|
||||
// Контактный урон по игроку.
|
||||
if (e.atkTimer > 0) e.atkTimer--;
|
||||
if (dist(e.x, e.y, p.x, p.y) < (e.w + p.w) / 2 && p.invTimer <= 0 && e.atkTimer <= 0) {
|
||||
p.hp -= e.damage;
|
||||
p.invTimer = PLAYER.invFrames;
|
||||
e.atkTimer = ENEMY.attackCooldown;
|
||||
if (p.hp <= 0) {
|
||||
p.hp = 0;
|
||||
this.gameOver = true;
|
||||
for (const parent of deadSplitters) newEnemies.push(...spawnSplitterChildren(parent));
|
||||
room.enemies.push(...newEnemies);
|
||||
room.enemies = room.enemies.filter((en) => en.alive);
|
||||
return aliveCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Распад splitter'ов на двух fast.
|
||||
for (const parent of deadSplitters) {
|
||||
newEnemies.push(...spawnSplitterChildren(parent));
|
||||
}
|
||||
|
||||
room.enemies.push(...newEnemies);
|
||||
// Убираем мёртвых — иначе массив растёт и зашумляет итерации/рендер.
|
||||
room.enemies = room.enemies.filter((en) => en.alive);
|
||||
return aliveCount;
|
||||
}
|
||||
|
||||
// ── Переходы и победа ─────────────────────────────────────
|
||||
|
||||
private checkTransition(input: InputState): void {
|
||||
const p = this.player;
|
||||
if (p.transCD > 0) return;
|
||||
const room = this.curRoom;
|
||||
if (!room.cleared) return;
|
||||
|
||||
const col = Math.floor((p.x - OX) / TILE);
|
||||
const row = Math.floor((p.y - OY) / TILE);
|
||||
|
||||
if (row === 0 && room.doors.up && DOOR.up.cols.includes(col) && pressingDir(input, 'up')) {
|
||||
if (this.roomMap.has(this.cc, this.cr - 1)) { this.cr--; this.enterRoom('down'); return; }
|
||||
}
|
||||
if (row === ROWS - 1 && room.doors.down && DOOR.down.cols.includes(col) && pressingDir(input, 'down')) {
|
||||
if (this.roomMap.has(this.cc, this.cr + 1)) { this.cr++; this.enterRoom('up'); return; }
|
||||
}
|
||||
if (col === 0 && room.doors.left && DOOR.left.rows.includes(row) && pressingDir(input, 'left')) {
|
||||
if (this.roomMap.has(this.cc - 1, this.cr)) { this.cc--; this.enterRoom('right'); return; }
|
||||
}
|
||||
if (col === COLS - 1 && room.doors.right && DOOR.right.rows.includes(row) && pressingDir(input, 'right')) {
|
||||
if (this.roomMap.has(this.cc + 1, this.cr)) { this.cc++; this.enterRoom('left'); return; }
|
||||
}
|
||||
}
|
||||
|
||||
/** Спуск на следующий этаж: новая карта, усиленные враги, HP/оружие сохраняются. */
|
||||
private descend(): void {
|
||||
this.floor++;
|
||||
this.floorRules = scaleRulesForFloor(this.rules, this.floor);
|
||||
|
||||
const p = this.player;
|
||||
const savedHp = p.hp;
|
||||
const savedMode = p.mode;
|
||||
|
||||
const floorSeed = this.rules.seed !== undefined ? this.rules.seed + this.floor : undefined;
|
||||
this.rng = floorSeed !== undefined ? new Rng(floorSeed) : new Rng();
|
||||
|
||||
this.roomMap = new RoomMap(this.rng, this.floorRules);
|
||||
this.cc = 0;
|
||||
this.cr = 0;
|
||||
this.meleeSwing = null;
|
||||
|
||||
p.hp = savedHp;
|
||||
p.atkCD = 0;
|
||||
p.moveDir = 'up';
|
||||
this.enterRoom('up');
|
||||
p.mode = savedMode;
|
||||
}
|
||||
|
||||
private checkWin(): void {
|
||||
for (const room of this.roomMap.rooms.values()) {
|
||||
if (room.type === 'boss' && room.cleared) {
|
||||
if (this.rules.endless) {
|
||||
this.descend();
|
||||
} else {
|
||||
this.won = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { CHEST } from '../../config';
|
||||
import type { Box } from '../types';
|
||||
|
||||
export class Chest {
|
||||
x: number;
|
||||
y: number;
|
||||
hp: number;
|
||||
readonly w = CHEST.size;
|
||||
readonly h = CHEST.size;
|
||||
|
||||
constructor(x: number, y: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.hp = CHEST.hp;
|
||||
}
|
||||
|
||||
get box(): Box {
|
||||
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
|
||||
}
|
||||
|
||||
get alive(): boolean {
|
||||
return this.hp > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ENEMY_STATS } from '../../config';
|
||||
import type { Box, EnemyType } from '../types';
|
||||
|
||||
/**
|
||||
* Враг. Характеристики берутся из таблицы ENEMY_STATS по типу.
|
||||
* Чтобы добавить новый тип врага — допиши строку в ENEMY_STATS (config.ts)
|
||||
* и тип в EnemyType (core/types.ts). Логика и спавн подхватят автоматически.
|
||||
*/
|
||||
export class Enemy {
|
||||
x: number;
|
||||
y: number;
|
||||
prevX: number;
|
||||
prevY: number;
|
||||
readonly type: EnemyType;
|
||||
readonly w: number;
|
||||
readonly h: number;
|
||||
hp: number;
|
||||
readonly maxHp: number;
|
||||
readonly speed: number;
|
||||
readonly damage: number;
|
||||
knx = 0; // отбрасывание по X
|
||||
kny = 0; // отбрасывание по Y
|
||||
hitTimer = 0; // мигание при попадании (шаги)
|
||||
atkTimer = 0; // перезарядка контактного удара (шаги)
|
||||
burnTimer = 0; // тиков до конца горения (0 = не горит)
|
||||
burnDamage = 1; // урон за тик горения
|
||||
burnInterval = 10; // как часто горение наносит урон
|
||||
chargeTimer = 0; // перезарядка рывка для charger (шаги)
|
||||
shootTimer = 0; // перезарядка стрельбы для shooter (шаги)
|
||||
phase = 1; // фаза босса (мультифазные боссы на этажах 5/10/15)
|
||||
phaseChanged = false; // флаг для рендера (сброс на след. шаге)
|
||||
spawnTimer = 0; // перезарядка спавна миньонов для босса
|
||||
|
||||
/**
|
||||
* mods — множители из правил уровня (см. core/rules.ts). По умолчанию 1,
|
||||
* поэтому `new Enemy(x, y, type)` даёт базовый баланс из ENEMY_STATS.
|
||||
*/
|
||||
constructor(x: number, y: number, type: EnemyType, mods: { hpMul?: number; speedMul?: number } = {}) {
|
||||
this.x = this.prevX = x;
|
||||
this.y = this.prevY = y;
|
||||
this.type = type;
|
||||
const s = ENEMY_STATS[type];
|
||||
this.w = s.size;
|
||||
this.h = s.size;
|
||||
this.maxHp = Math.max(1, Math.round(s.hp * (mods.hpMul ?? 1)));
|
||||
this.hp = this.maxHp;
|
||||
this.speed = s.speed * (mods.speedMul ?? 1);
|
||||
this.damage = s.damage;
|
||||
}
|
||||
|
||||
get box(): Box {
|
||||
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
|
||||
}
|
||||
|
||||
get alive(): boolean {
|
||||
return this.hp > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MELEE, DIR } from '../../config';
|
||||
import type { Dir, Box } from '../types';
|
||||
|
||||
/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на life шагов. */
|
||||
export class MeleeSwing {
|
||||
readonly dir: Dir;
|
||||
life = MELEE.life;
|
||||
readonly maxLife: number;
|
||||
readonly damage: number;
|
||||
readonly kb: number;
|
||||
readonly box: Box;
|
||||
|
||||
constructor(x: number, y: number, dir: Dir, overrides?: { damage?: number; knockback?: number; life?: number; sizeMul?: number }) {
|
||||
this.dir = dir;
|
||||
this.damage = overrides?.damage ?? MELEE.damage;
|
||||
this.kb = overrides?.knockback ?? MELEE.knockback;
|
||||
if (overrides?.life !== undefined) this.life = overrides.life;
|
||||
this.maxLife = this.life;
|
||||
const { reach: d } = MELEE;
|
||||
const size = MELEE.size * (overrides?.sizeMul ?? 1);
|
||||
const [dx, dy] = DIR[dir];
|
||||
this.box = {
|
||||
x: x + (dx > 0 ? d : dx < 0 ? -d - size : -size / 2),
|
||||
y: y + (dy > 0 ? d : dy < 0 ? -d - size : -size / 2),
|
||||
w: size,
|
||||
h: size,
|
||||
};
|
||||
}
|
||||
|
||||
get alive(): boolean {
|
||||
return this.life > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Box } from '../types';
|
||||
import type { WeaponId } from '../weapons';
|
||||
import type { ItemId } from '../items';
|
||||
|
||||
/** Что выпало из сундука: новое оружие ИЛИ пассивный предмет. */
|
||||
export type PickupKind = 'weapon' | 'item';
|
||||
|
||||
/**
|
||||
* Пикап на полу. Может быть либо оружие (заменяет экипированный слот),
|
||||
* либо пассивный предмет (модифицирует статы игрока при подборе).
|
||||
*/
|
||||
export class Pickup {
|
||||
x: number;
|
||||
y: number;
|
||||
readonly kind: PickupKind;
|
||||
/** ID оружия, если kind === 'weapon'. */
|
||||
readonly weaponId?: WeaponId;
|
||||
/** ID предмета, если kind === 'item'. */
|
||||
readonly itemId?: ItemId;
|
||||
readonly w = 30;
|
||||
readonly h = 30;
|
||||
|
||||
private constructor(x: number, y: number, kind: PickupKind, weaponId?: WeaponId, itemId?: ItemId) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.kind = kind;
|
||||
this.weaponId = weaponId;
|
||||
this.itemId = itemId;
|
||||
}
|
||||
|
||||
static weapon(x: number, y: number, weaponId: WeaponId): Pickup {
|
||||
return new Pickup(x, y, 'weapon', weaponId);
|
||||
}
|
||||
|
||||
static item(x: number, y: number, itemId: ItemId): Pickup {
|
||||
return new Pickup(x, y, 'item', undefined, itemId);
|
||||
}
|
||||
|
||||
get box(): Box {
|
||||
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Используйте Pickup. WeaponPickup оставлен как псевдоним для
|
||||
* обратной совместимости со старым кодом/тестами.
|
||||
*/
|
||||
export const WeaponPickup = Pickup;
|
||||
@@ -0,0 +1,107 @@
|
||||
import { PLAYER, MODE_RANGED, MODE_MELEE } from '../../config';
|
||||
import { WEAPONS, type WeaponId, type WeaponDef } from '../weapons';
|
||||
import type { CombatMode, Box, Dir } from '../types';
|
||||
|
||||
/**
|
||||
* Статы-множители, которые модифицируют базовые характеристики оружия.
|
||||
* Все начинают с 1 (нейтрально); пассивные предметы и баффы их меняют.
|
||||
* Сделано мультипликативно поверх WeaponDef, чтобы предметы и оружие
|
||||
* комбинировались независимо (как в Isaac: апгрейды работают с любым оружием).
|
||||
*/
|
||||
export interface PlayerStats {
|
||||
/** Множитель урона выстрела/взмаха. */
|
||||
damageMul: number;
|
||||
/** Множитель скорости атаки (больше → чаще стреляет).Cooldown = base / fireRateMul. */
|
||||
fireRateMul: number;
|
||||
/** Множитель дальности полёта снаряда (в шагах жизни). */
|
||||
rangeMul: number;
|
||||
/** Множитель скорости полёта снаряда. */
|
||||
shotSpeedMul: number;
|
||||
}
|
||||
|
||||
export const NEUTRAL_STATS: PlayerStats = {
|
||||
damageMul: 1,
|
||||
fireRateMul: 1,
|
||||
rangeMul: 1,
|
||||
shotSpeedMul: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Игрок. Только данные и геометрия — никакой отрисовки.
|
||||
* prevX/prevY хранят позицию на прошлом шаге для плавной интерполяции
|
||||
* при рендере (см. render/).
|
||||
*/
|
||||
export class Player {
|
||||
x = 0;
|
||||
y = 0;
|
||||
prevX = 0;
|
||||
prevY = 0;
|
||||
readonly w = PLAYER.size;
|
||||
readonly h = PLAYER.size;
|
||||
readonly speed: number;
|
||||
hp: number;
|
||||
readonly maxHp: number;
|
||||
mode: CombatMode = MODE_RANGED;
|
||||
facing: Dir = 'up'; // куда смотрит/целится
|
||||
moveDir: Dir = 'up'; // последнее направление движения
|
||||
atkCD = 0; // перезарядка атаки (шаги)
|
||||
invTimer = 0; // неуязвимость (шаги)
|
||||
transCD = 0; // блок перехода между комнатами (шаги)
|
||||
|
||||
/** Ровно 2 слота под оружие. */
|
||||
weapons: [WeaponDef, WeaponDef] = [WEAPONS.tears, WEAPONS.melee];
|
||||
/** 0 или 1 — какой слот сейчас экипирован. */
|
||||
equipped: 0 | 1 = 0;
|
||||
|
||||
/** Текущие множители. Меняются предметами/баффами. */
|
||||
stats: PlayerStats = { ...NEUTRAL_STATS };
|
||||
|
||||
/** Переопределения из правил уровня; по умолчанию — баланс из config. */
|
||||
constructor(rules: { maxHp?: number; speed?: number } = {}) {
|
||||
this.maxHp = rules.maxHp ?? PLAYER.maxHp;
|
||||
this.hp = this.maxHp;
|
||||
this.speed = rules.speed ?? PLAYER.speed;
|
||||
}
|
||||
|
||||
/** Хитбокс с центром в (x, y). */
|
||||
get box(): Box {
|
||||
return { x: this.x - this.w / 2, y: this.y - this.h / 2, w: this.w, h: this.h };
|
||||
}
|
||||
|
||||
get currentWeapon(): WeaponDef {
|
||||
return this.weapons[this.equipped];
|
||||
}
|
||||
|
||||
/** Эффективный урон оружия с учётом статов игрока. */
|
||||
effectiveDamage(w: WeaponDef): number {
|
||||
return w.damage * this.stats.damageMul;
|
||||
}
|
||||
|
||||
/** Эффективная перезарядка оружия с учётом скорости атаки. */
|
||||
effectiveCooldown(w: WeaponDef): number {
|
||||
return Math.max(1, Math.round(w.cooldown / this.stats.fireRateMul));
|
||||
}
|
||||
|
||||
/** Подобрать оружие — заменяет текущий экипированный слот. */
|
||||
addWeapon(id: WeaponId): void {
|
||||
this.weapons[this.equipped] = WEAPONS[id];
|
||||
this.mode = WEAPONS[id].type === 'ranged' ? MODE_RANGED : MODE_MELEE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Увеличить максимальное HP на bonus и подлечить на ту же величину.
|
||||
* maxHp readonly снаружи, поэтому меняем через этот метод — он же не
|
||||
* позволяет уйти в отрицательные значения.
|
||||
*/
|
||||
growMaxHp(bonus: number): void {
|
||||
if (bonus <= 0) return;
|
||||
(this as { maxHp: number }).maxHp += bonus;
|
||||
this.hp = Math.min(this.maxHp, this.hp + bonus);
|
||||
}
|
||||
|
||||
/** Поставить позицию мгновенно, сбросив интерполяцию (телепорт). */
|
||||
place(x: number, y: number): void {
|
||||
this.x = this.prevX = x;
|
||||
this.y = this.prevY = y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { PROJECTILE } from '../../config';
|
||||
import type { ProjectileType } from '../types';
|
||||
|
||||
/** Снаряд. Летит по прямой, пока не врежется или не истечёт life. */
|
||||
export class Projectile {
|
||||
x: number;
|
||||
y: number;
|
||||
prevX: number;
|
||||
prevY: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
readonly type: ProjectileType;
|
||||
readonly r = PROJECTILE.radius;
|
||||
speed = PROJECTILE.speed;
|
||||
damage = PROJECTILE.damage;
|
||||
life = PROJECTILE.life;
|
||||
hostile = false; // true = вражеский снаряд, бьёт игрока
|
||||
burnDuration = 0;
|
||||
burnDamage = 1;
|
||||
burnInterval = 10;
|
||||
explosionRadius = 0;
|
||||
beamRadius = 0;
|
||||
|
||||
constructor(x: number, y: number, dx: number, dy: number, type: ProjectileType = 'tear') {
|
||||
this.x = this.prevX = x;
|
||||
this.y = this.prevY = y;
|
||||
this.dx = dx;
|
||||
this.dy = dy;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
get alive(): boolean {
|
||||
return this.life > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* items.ts — пассивные предметы (как в Isaac).
|
||||
*
|
||||
* Предмет модифицирует статы игрока (damageMul, fireRateMul и т.д.).
|
||||
* В отличие от оружия (которое занимает слот и определяет тип атаки),
|
||||
* предметов можно собрать сколько угодно — они стакаются в общей сумме
|
||||
* статов. Это база для «билдов»: чувак, накопивший +damage и +fireRate,
|
||||
* к боссу придёт совсем с другой огневой мощью.
|
||||
*/
|
||||
import type { PlayerStats } from './entities/Player';
|
||||
|
||||
export type ItemId =
|
||||
| 'sad-onion' // +скорострельность
|
||||
| 'cricket-head' // +урон
|
||||
| 'lemon-mishap' // +дальность
|
||||
| 'lucky-toe' // +всё понемногу
|
||||
| 'blood-penny' // +HP
|
||||
| 'speed-ball'; // +скорость полёта снаряда
|
||||
|
||||
export interface ItemDef {
|
||||
id: ItemId;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Изменения стат, которые применяются при подборе. */
|
||||
stats?: Partial<PlayerStats>;
|
||||
/** Сколько добавить к максимальному HP (и текущему). */
|
||||
maxHpBonus?: number;
|
||||
}
|
||||
|
||||
export const ITEMS: Record<ItemId, ItemDef> = {
|
||||
'sad-onion': {
|
||||
id: 'sad-onion', name: 'Грустный лук',
|
||||
description: '+35% к скорострельности.',
|
||||
stats: { fireRateMul: 0.35 }, // это дельта, применяется как += 0.35
|
||||
},
|
||||
'cricket-head': {
|
||||
id: 'cricket-head', name: 'Голова сверчка',
|
||||
description: '+50% к урону.',
|
||||
stats: { damageMul: 0.5 },
|
||||
},
|
||||
'lemon-mishap': {
|
||||
id: 'lemon-mishap', name: 'Лимонная неприятность',
|
||||
description: '+60% к дальности.',
|
||||
stats: { rangeMul: 0.6 },
|
||||
},
|
||||
'speed-ball': {
|
||||
id: 'speed-ball', name: 'Скоростной шар',
|
||||
description: '+40% к скорости снаряда.',
|
||||
stats: { shotSpeedMul: 0.4 },
|
||||
},
|
||||
'lucky-toe': {
|
||||
id: 'lucky-toe', name: 'Счастливый палец',
|
||||
description: '+15% урон, +15% скорострельность.',
|
||||
stats: { damageMul: 0.15, fireRateMul: 0.15 },
|
||||
},
|
||||
'blood-penny': {
|
||||
id: 'blood-penny', name: 'Кровавый пенс',
|
||||
description: '+2 к макс. HP и лечит на 2.',
|
||||
maxHpBonus: 2,
|
||||
},
|
||||
};
|
||||
|
||||
/** Все айдishники предметов — для случайного дропа. */
|
||||
export const ALL_ITEM_IDS: readonly ItemId[] = Object.keys(ITEMS) as ItemId[];
|
||||
|
||||
/**
|
||||
* Применить предмет к статам/игроку. Используется при подборе.
|
||||
* Стаки: каждый предмет модифицирует текущие множители дельтой.
|
||||
*/
|
||||
export function applyItem(stats: PlayerStats, item: ItemDef, onMaxHp: (bonus: number) => void): void {
|
||||
if (item.stats) {
|
||||
if (item.stats.damageMul) stats.damageMul += item.stats.damageMul;
|
||||
if (item.stats.fireRateMul) stats.fireRateMul += item.stats.fireRateMul;
|
||||
if (item.stats.rangeMul) stats.rangeMul += item.stats.rangeMul;
|
||||
if (item.stats.shotSpeedMul) stats.shotSpeedMul += item.stats.shotSpeedMul;
|
||||
}
|
||||
if (item.maxHpBonus) onMaxHp(item.maxHpBonus);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* rng.ts — генератор случайных чисел с поддержкой seed.
|
||||
*
|
||||
* Зачем не просто Math.random(): с фиксированным seed карта и спавн
|
||||
* становятся воспроизводимыми. Это бесценно для отладки («дай мне ту же
|
||||
* самую кривую генерацию») и для тестов (см. tests/). Вся игровая логика
|
||||
* получает один экземпляр Rng и использует только его — никаких прямых
|
||||
* вызовов Math.random() в ядре.
|
||||
*/
|
||||
|
||||
export class Rng {
|
||||
private state: number;
|
||||
|
||||
/** Без seed — случайный старт; с seed — детерминированная цепочка. */
|
||||
constructor(seed: number = autoSeed()) {
|
||||
// 0 — валидный seed; авто-seed включается только когда аргумент не передан.
|
||||
this.state = seed >>> 0;
|
||||
}
|
||||
|
||||
/** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
|
||||
next(): number {
|
||||
this.state = (this.state + 0x6d2b79f5) >>> 0;
|
||||
let t = this.state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
/** Случайное вещественное в [a, b). */
|
||||
float(a: number, b: number): number {
|
||||
return this.next() * (b - a) + a;
|
||||
}
|
||||
|
||||
/** Случайное целое в [a, b] включительно. */
|
||||
int(a: number, b: number): number {
|
||||
return Math.floor(this.float(a, b + 1));
|
||||
}
|
||||
|
||||
/** true с вероятностью p (0..1). */
|
||||
chance(p: number): boolean {
|
||||
return this.next() < p;
|
||||
}
|
||||
|
||||
/** Случайный элемент массива. */
|
||||
pick<T>(arr: readonly T[]): T {
|
||||
if (arr.length === 0) throw new Error('Rng.pick: пустой массив');
|
||||
return arr[this.int(0, arr.length - 1)];
|
||||
}
|
||||
|
||||
/** Перемешивание Фишера–Йейтса на месте. */
|
||||
shuffle<T>(arr: T[]): T[] {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = this.int(0, i);
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
|
||||
const AUTO_SEED_STEP = 0x9e3779b9;
|
||||
let autoSeedCounter = 0;
|
||||
|
||||
/**
|
||||
* Seed для нового нефиксированного забега. Энтропия берётся только внутри Rng:
|
||||
* остальная игровая логика по-прежнему получает все случайные числа через next().
|
||||
*/
|
||||
function autoSeed(): number {
|
||||
autoSeedCounter = (autoSeedCounter + AUTO_SEED_STEP) >>> 0;
|
||||
let seed = Date.now() >>> 0;
|
||||
const crypto = globalThis.crypto;
|
||||
if (crypto?.getRandomValues) {
|
||||
const value = new Uint32Array(1);
|
||||
crypto.getRandomValues(value);
|
||||
seed ^= value[0];
|
||||
}
|
||||
return (seed ^ autoSeedCounter) >>> 0;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* rules.ts — ПРАВИЛА УРОВНЯ (конфигурация забега).
|
||||
*
|
||||
* Мир по-прежнему генерируется процедурно (каждый забег — новый), но теперь
|
||||
* параметризуется набором правил: размер данжена, плотность и сила врагов,
|
||||
* здоровье игрока, фиксированный seed. На старте игрок выбирает один из
|
||||
* пресетов (меню), и `Game` создаётся с этими правилами.
|
||||
*
|
||||
* Как добавить свой уровень: допиши объект в PRESETS — он сразу появится в меню.
|
||||
* Геометрия (размер тайла/комнаты, геометрия дверей) остаётся в config.ts: это
|
||||
* не «правила уровня», а константы движка.
|
||||
*/
|
||||
import { PLAYER, ENEMY, MIN_ROOMS, EXTRA_ROOMS, MAP_RADIUS, FLOOR_SCALING } from '../config';
|
||||
|
||||
export interface LevelRules {
|
||||
/** Машинный id (для сохранений/выбора). */
|
||||
id: string;
|
||||
/** Название для меню. */
|
||||
name: string;
|
||||
/** Короткое описание для меню. */
|
||||
description: string;
|
||||
/** Фиксированный seed генерации. undefined → случайный каждый забег. */
|
||||
seed?: number;
|
||||
/** Бесконечный спуск: после босса — новый этаж с усилением, а не победа. */
|
||||
endless?: boolean;
|
||||
|
||||
/** Параметры генерации карты. */
|
||||
map: {
|
||||
minRooms: number;
|
||||
extraRooms: number;
|
||||
mapRadius: number;
|
||||
};
|
||||
|
||||
/** Параметры игрока. */
|
||||
player: {
|
||||
maxHp: number;
|
||||
speed: number;
|
||||
};
|
||||
|
||||
/** Параметры врагов (множители поверх базовых из config.ENEMY_STATS). */
|
||||
enemies: {
|
||||
densityMul: number; // множитель числа врагов в обычной комнате
|
||||
fastChance: number; // доля быстрых врагов
|
||||
hpMul: number; // множитель HP всех врагов
|
||||
speedMul: number; // множитель скорости
|
||||
bossHpMul: number; // отдельный множитель HP босса
|
||||
};
|
||||
}
|
||||
|
||||
/** Базовые правила = текущий «ванильный» баланс из config. */
|
||||
export const DEFAULT_RULES: LevelRules = {
|
||||
id: 'standard',
|
||||
name: 'Стандарт',
|
||||
description: 'Классический забег. Сбалансированный данжен.',
|
||||
map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
|
||||
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
|
||||
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
|
||||
};
|
||||
|
||||
/** Пресеты для меню. Первый — по умолчанию. */
|
||||
export const PRESETS: LevelRules[] = [
|
||||
DEFAULT_RULES,
|
||||
{
|
||||
id: 'big',
|
||||
name: 'Большой данжен',
|
||||
description: 'Больше комнат — длиннее забег.',
|
||||
map: { minRooms: 14, extraRooms: 6, mapRadius: 4 },
|
||||
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
|
||||
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
|
||||
},
|
||||
{
|
||||
id: 'hardcore',
|
||||
name: 'Хардкор',
|
||||
description: 'Мало HP, больше быстрых и живучих врагов.',
|
||||
map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
|
||||
player: { maxHp: 3, speed: PLAYER.speed },
|
||||
enemies: { densityMul: 1.5, fastChance: 0.55, hpMul: 1.4, speedMul: 1.15, bossHpMul: 1.5 },
|
||||
},
|
||||
{
|
||||
id: 'explorer',
|
||||
name: 'Исследователь',
|
||||
description: 'Мирно: много HP, мало слабых врагов — просто ходить и изучать.',
|
||||
map: { minRooms: 12, extraRooms: 4, mapRadius: 4 },
|
||||
player: { maxHp: 10, speed: PLAYER.speed * 1.1 },
|
||||
enemies: { densityMul: 0.5, fastChance: 0.15, hpMul: 0.7, speedMul: 0.9, bossHpMul: 0.8 },
|
||||
},
|
||||
{
|
||||
id: 'daily',
|
||||
name: 'Фикс-сид',
|
||||
description: 'Один и тот же данжен каждый раз (seed=2026) — удобно тренироваться.',
|
||||
seed: 2026,
|
||||
map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
|
||||
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
|
||||
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
|
||||
},
|
||||
{
|
||||
id: 'endless',
|
||||
name: 'Бесконечный спуск',
|
||||
description: 'После босса — спуск на новый этаж. Враги сильнее, комнат больше. HP и оружие сохраняются.',
|
||||
endless: true,
|
||||
map: { minRooms: MIN_ROOMS, extraRooms: EXTRA_ROOMS, mapRadius: MAP_RADIUS },
|
||||
player: { maxHp: PLAYER.maxHp, speed: PLAYER.speed },
|
||||
enemies: { densityMul: 1, fastChance: ENEMY.fastChance, hpMul: 1, speedMul: 1, bossHpMul: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Возвращает правила для указанного этажа на основе базовых правил.
|
||||
* С каждым этажом враги сильнее и комнат больше.
|
||||
*/
|
||||
export function scaleRulesForFloor(base: LevelRules, floor: number): LevelRules {
|
||||
const f = floor - 1;
|
||||
const cap = (v: number): number => Math.round(v * 100) / 100;
|
||||
return {
|
||||
...base,
|
||||
map: {
|
||||
...base.map,
|
||||
minRooms: base.map.minRooms + f * FLOOR_SCALING.roomsPerFloor,
|
||||
mapRadius: Math.min(base.map.mapRadius + Math.floor(f / 3), 6),
|
||||
},
|
||||
enemies: {
|
||||
densityMul: cap(base.enemies.densityMul + f * FLOOR_SCALING.densityMulPerFloor),
|
||||
fastChance: Math.min(base.enemies.fastChance + f * FLOOR_SCALING.fastChancePerFloor, 0.8),
|
||||
hpMul: cap(base.enemies.hpMul + f * FLOOR_SCALING.hpMulPerFloor),
|
||||
speedMul: cap(base.enemies.speedMul + f * FLOOR_SCALING.speedMulPerFloor),
|
||||
bossHpMul: cap(base.enemies.bossHpMul + f * FLOOR_SCALING.bossHpMulPerFloor),
|
||||
},
|
||||
endless: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { BOSS, ENEMY, OX, OY, TILE, COLS, ROWS } from '../../config';
|
||||
import { moveEntity } from './movement';
|
||||
import { Enemy } from '../entities/Enemy';
|
||||
import { Projectile } from '../entities/Projectile';
|
||||
import type { Room } from '../world/Room';
|
||||
import type { Player } from '../entities/Player';
|
||||
import type { Rng } from '../rng';
|
||||
import type { LevelRules } from '../rules';
|
||||
|
||||
/**
|
||||
* Контекст, нужный ИИ врагов на одном шаге. Передаётся извне (Game), чтобы
|
||||
* сами функции ИИ оставались чистыми от глобального состояния и их было
|
||||
* удобно тестировать.
|
||||
*/
|
||||
export interface AIContext {
|
||||
room: Room;
|
||||
player: Player;
|
||||
rng: Rng;
|
||||
floor: number;
|
||||
floorRules: LevelRules;
|
||||
/** Сюда босс складывает свежеспавненных миньёнов — Game добавит их в room.enemies. */
|
||||
newEnemies: Enemy[];
|
||||
}
|
||||
|
||||
/** Этаж кратный 5, начиная с 5 — на нём босс получает фазы и спавн миньёнов. */
|
||||
export function isMilestoneBossFloor(floor: number): boolean {
|
||||
return floor % 5 === 0 && floor >= 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Раздвоение splitter'а при смерти: спавнит двух мелких `fast` по бокам.
|
||||
* Возвращает свежезаспавненных врагов — Game добавит их в room.enemies.
|
||||
*/
|
||||
export function spawnSplitterChildren(parent: Enemy): Enemy[] {
|
||||
const offset = 14;
|
||||
return [
|
||||
new Enemy(parent.x - offset, parent.y, 'fast'),
|
||||
new Enemy(parent.x + offset, parent.y, 'fast'),
|
||||
];
|
||||
}
|
||||
|
||||
type AIHandler = (e: Enemy, ctx: AIContext) => void;
|
||||
|
||||
/**
|
||||
* Диспетчер ИИ по типу врага. Добавишь новый тип — допиши ветку здесь
|
||||
* (и в EnemyType/config). Заменяет прежний if/else-if каскад в Game.ts.
|
||||
*/
|
||||
export function runAI(e: Enemy, ctx: AIContext): void {
|
||||
const handler = pickHandler(e, ctx.floor);
|
||||
handler(e, ctx);
|
||||
}
|
||||
|
||||
function pickHandler(e: Enemy, floor: number): AIHandler {
|
||||
if (e.type === 'boss' && isMilestoneBossFloor(floor)) return updateMilestoneBoss;
|
||||
if (e.type === 'shooter') return updateShooter;
|
||||
if (e.type === 'charger') return updateCharger;
|
||||
// normal, fast, tank, обычный boss — просто догоняют.
|
||||
return updateChaser;
|
||||
}
|
||||
|
||||
/** Стандартное преследование (normal, fast, tank, обычный boss). */
|
||||
function updateChaser(e: Enemy, ctx: AIContext): void {
|
||||
const { player: p, room } = ctx;
|
||||
const dx = p.x - e.x;
|
||||
const dy = p.y - e.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > 0 && d < ENEMY.aggroRange) {
|
||||
moveEntity(e, (dx / d) * e.speed, (dy / d) * e.speed, room);
|
||||
}
|
||||
}
|
||||
|
||||
/** Зарядчик: бежит прямо на игрока, время от времени делая рывок ×2.5. */
|
||||
function updateCharger(e: Enemy, ctx: AIContext): void {
|
||||
const { player: p, room } = ctx;
|
||||
if (e.chargeTimer > 0) e.chargeTimer--;
|
||||
const dx = p.x - e.x;
|
||||
const dy = p.y - e.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > 0 && d < ENEMY.aggroRange) {
|
||||
const speedMul = e.chargeTimer <= 0 && d < 150 ? 2.5 : 1.0;
|
||||
if (speedMul > 1) e.chargeTimer = 40; // перезарядка рывка
|
||||
moveEntity(e, (dx / d) * e.speed * speedMul, (dy / d) * e.speed * speedMul, room);
|
||||
}
|
||||
}
|
||||
|
||||
/** Стрелок: держит дистанцию ~150–200 px, периодически стреляет в игрока. */
|
||||
function updateShooter(e: Enemy, ctx: AIContext): void {
|
||||
const { player: p, room } = ctx;
|
||||
const dx = p.x - e.x;
|
||||
const dy = p.y - e.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
|
||||
if (d > 0 && d < ENEMY.aggroRange) {
|
||||
if (d < 150) {
|
||||
// Слишком близко — отступает.
|
||||
moveEntity(e, -(dx / d) * e.speed, -(dy / d) * e.speed, room);
|
||||
} else {
|
||||
moveEntity(e, (dx / d) * e.speed * 0.5, (dy / d) * e.speed * 0.5, room);
|
||||
}
|
||||
}
|
||||
|
||||
// Стрельба.
|
||||
if (e.shootTimer > 0) e.shootTimer--;
|
||||
if (e.shootTimer <= 0 && d < 350 && d > 40) {
|
||||
e.shootTimer = 45;
|
||||
const nd = d || 1;
|
||||
const t = new Projectile(e.x, e.y, dx / nd, dy / nd, 'tear');
|
||||
t.hostile = true;
|
||||
t.damage = 1;
|
||||
t.speed = 3.5;
|
||||
t.life = 60;
|
||||
ctx.room.tears.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
/** Milestone-босс (этаж 5/10/15): фазы HP, стрельба с фазы 2, миньёны с фазы 3. */
|
||||
function updateMilestoneBoss(e: Enemy, ctx: AIContext): void {
|
||||
const { player: p, room, rng, floor, floorRules, newEnemies } = ctx;
|
||||
const maxPhase = floor <= 5 ? 2 : 3;
|
||||
const hpRatio = e.hp / e.maxHp;
|
||||
|
||||
let targetPhase = 1;
|
||||
if (maxPhase >= 2 && hpRatio < 0.66) targetPhase = 2;
|
||||
if (maxPhase >= 3 && hpRatio < 0.33) targetPhase = 3;
|
||||
|
||||
if (targetPhase > e.phase) {
|
||||
e.phase = targetPhase;
|
||||
e.phaseChanged = true;
|
||||
e.hitTimer = 15; // визуальная вспышка (для рендера)
|
||||
}
|
||||
|
||||
const phaseSpeedMul = targetPhase >= 3 ? 1.8 : targetPhase === 2 ? 1.35 : 1.0;
|
||||
const effectiveSpeed = e.speed * phaseSpeedMul;
|
||||
|
||||
// Движение к игроку.
|
||||
const dx = p.x - e.x;
|
||||
const dy = p.y - e.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > 0 && d < ENEMY.aggroRange) {
|
||||
moveEntity(e, (dx / d) * effectiveSpeed, (dy / d) * effectiveSpeed, room);
|
||||
}
|
||||
|
||||
// Стрельба снарядами (фаза 2+).
|
||||
if (targetPhase >= 2) {
|
||||
if (e.shootTimer > 0) e.shootTimer--;
|
||||
const shootCD = targetPhase >= 3 ? 25 : 40;
|
||||
if (e.shootTimer <= 0 && d < 400 && d > 50) {
|
||||
e.shootTimer = shootCD;
|
||||
const nd = d || 1;
|
||||
const t = new Projectile(e.x, e.y, dx / nd, dy / nd, 'tear');
|
||||
t.hostile = true;
|
||||
t.damage = 1 + Math.floor(floor / 5);
|
||||
t.speed = 3;
|
||||
t.life = 60;
|
||||
room.tears.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Спавн миньёнов (фаза 3), но не больше BOSS.maxMinions живых — иначе комната не зачистится.
|
||||
if (targetPhase >= 3) {
|
||||
if (e.spawnTimer > 0) e.spawnTimer--;
|
||||
const aliveMinions = room.enemies.filter((en) => en !== e && en.alive).length;
|
||||
if (e.spawnTimer <= 0 && aliveMinions < BOSS.maxMinions) {
|
||||
e.spawnTimer = BOSS.minionInterval;
|
||||
const mx = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
|
||||
const my = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
|
||||
const er = floorRules.enemies;
|
||||
newEnemies.push(new Enemy(mx, my, 'fast', { hpMul: er.hpMul * 1.5, speedMul: er.speedMul * 1.2 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ROWS, COLS, DOOR, TILE, OX, OY, T_WALL } from '../../config';
|
||||
import type { Room } from '../world/Room';
|
||||
import type { Box } from '../types';
|
||||
|
||||
/**
|
||||
* Заблокирован ли тайл (col, row) для движения.
|
||||
* В дверных проёмах граница комнаты «прозрачна» — это позволяет хитбоксу
|
||||
* заехать за край и встать на дверь для перехода в соседнюю комнату.
|
||||
*/
|
||||
export function isBlocked(room: Room, col: number, row: number): boolean {
|
||||
if (row < 0 && room.doors.up && DOOR.up.cols.includes(col)) return false;
|
||||
if (row >= ROWS && room.doors.down && DOOR.down.cols.includes(col)) return false;
|
||||
if (col < 0 && room.doors.left && DOOR.left.rows.includes(row)) return false;
|
||||
if (col >= COLS && room.doors.right && DOOR.right.rows.includes(row)) return false;
|
||||
|
||||
if (row < 0 || row >= ROWS || col < 0 || col >= COLS) return true;
|
||||
return room.tiles[row][col] === T_WALL;
|
||||
}
|
||||
|
||||
/** Пересекает ли хитбокс хотя бы один заблокированный тайл. */
|
||||
export function collidesWall(box: Box, room: Room): boolean {
|
||||
const left = Math.floor((box.x - OX) / TILE);
|
||||
const right = Math.floor((box.x + box.w - OX) / TILE);
|
||||
const top = Math.floor((box.y - OY) / TILE);
|
||||
const bottom = Math.floor((box.y + box.h - OY) / TILE);
|
||||
|
||||
for (let row = top; row <= bottom; row++) {
|
||||
for (let col = left; col <= right; col++) {
|
||||
if (isBlocked(room, col, row)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { collidesWall } from './collision';
|
||||
import type { Room } from '../world/Room';
|
||||
import type { Box } from '../types';
|
||||
|
||||
/**
|
||||
* Сущность с прямоугольным хитбоксом и позицией, пригодная для скользящего
|
||||
* перемещения (движение разрешается по осям раздельно, скользя вдоль стен).
|
||||
*/
|
||||
export interface Movable {
|
||||
x: number;
|
||||
y: number;
|
||||
box: Box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сдвинуть сущность на (dx, dy) с разрешением коллизий по осям раздельно.
|
||||
* Применяет X (откатывая при столкновении), затем Y — это даёт «скольжение»
|
||||
* вдоль стен. Игрок, враги, боссы — все ходят через эту функцию, чтобы
|
||||
* поведение у стен было единым.
|
||||
*/
|
||||
export function moveEntity(e: Movable, dx: number, dy: number, room: Room): void {
|
||||
e.x += dx;
|
||||
if (collidesWall(e.box, room)) e.x -= dx;
|
||||
e.y += dy;
|
||||
if (collidesWall(e.box, room)) e.y -= dy;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { BOSS, ENEMY, PROJECTILE, TILE, OX, OY, COLS, ROWS, T_WALL } from '../../config';
|
||||
import { dist } from '../util';
|
||||
import { Projectile } from '../entities/Projectile';
|
||||
import type { Player, PlayerStats } from '../entities/Player';
|
||||
import type { Room } from '../world/Room';
|
||||
import type { WeaponDef } from '../weapons';
|
||||
|
||||
/**
|
||||
* Применяет к свежему снаряду характеристики оружия (урон, горение, радиус
|
||||
* взрыва) с учётом статов игрока (damageMul, rangeMul, shotSpeedMul).
|
||||
* Снаряд запоминает свойства на момент выстрела — поэтому смена оружия или
|
||||
* статов после не меняет урон уже летящей «слезы».
|
||||
*/
|
||||
export function applyWeaponProjectileStats(t: Projectile, w: WeaponDef, stats: PlayerStats | Player): void {
|
||||
const s: PlayerStats = 'stats' in stats ? stats.stats : stats;
|
||||
t.damage = w.damage * s.damageMul;
|
||||
t.burnDamage = (w.fireDmg ?? 1) * s.damageMul;
|
||||
t.burnInterval = w.fireInterval ?? 10;
|
||||
t.burnDuration = w.fireDuration ?? 0;
|
||||
t.explosionRadius = w.explosionRadius ?? 0;
|
||||
t.speed *= s.shotSpeedMul;
|
||||
t.life = Math.round(t.life * s.rangeMul);
|
||||
}
|
||||
|
||||
/** Взрыв бомбы: AoE-урон по всем врагам в радиусе, с отбрасыванием от центра. */
|
||||
export function explodeBomb(room: Room, t: Projectile): void {
|
||||
if (t.type !== 'bomb') return;
|
||||
const radius = t.explosionRadius || 60;
|
||||
for (const e of room.enemies) {
|
||||
if (!e.alive) continue;
|
||||
if (dist(t.x, t.y, e.x, e.y) < radius) {
|
||||
e.hp -= 3;
|
||||
e.hitTimer = ENEMY.hitFlash;
|
||||
const dx = e.x - t.x;
|
||||
const dy = e.y - t.y;
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
e.knx = (dx / d) * 12;
|
||||
e.kny = (dy / d) * 12;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** True, если снаряд вышел за пределы комнаты или уткнулся в стену. */
|
||||
export function projectileHitWall(t: Projectile, room: Room): boolean {
|
||||
const col = Math.floor((t.x - OX) / TILE);
|
||||
const row = Math.floor((t.y - OY) / TILE);
|
||||
if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return true;
|
||||
if (room.tiles[row][col] === T_WALL) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Параметры, общие для всех боссов: лимит миньёнов и интервал их спавна
|
||||
* вынесены в BOSS (см. config), чтобы их можно было крутить отдельно от ИИ.
|
||||
*/
|
||||
export const BOSS_LIMITS = {
|
||||
maxMinions: BOSS.maxMinions,
|
||||
minionInterval: BOSS.minionInterval,
|
||||
} as const;
|
||||
|
||||
/** Текущая скорость снаряда по умолчанию (для новых снарядов, не задающих свою). */
|
||||
export const PROJECTILE_DEFAULTS = {
|
||||
speed: PROJECTILE.speed,
|
||||
life: PROJECTILE.life,
|
||||
} as const;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { OX, OY, TILE, COLS, ROWS, DOOR, SPAWN } from '../../config';
|
||||
import { Enemy } from '../entities/Enemy';
|
||||
import { Chest } from '../entities/Chest';
|
||||
import { dist } from '../util';
|
||||
import type { Room } from '../world/Room';
|
||||
import type { Dir, EnemyType } from '../types';
|
||||
import type { Rng } from '../rng';
|
||||
import type { WeaponId } from '../weapons';
|
||||
import { DEFAULT_RULES, type LevelRules } from '../rules';
|
||||
|
||||
function isSpawnSpotClear(
|
||||
x: number,
|
||||
y: number,
|
||||
doorX: number,
|
||||
doorY: number,
|
||||
playerX: number,
|
||||
playerY: number,
|
||||
enemies: readonly Enemy[],
|
||||
): boolean {
|
||||
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) return false;
|
||||
if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) return false;
|
||||
for (const e of enemies) {
|
||||
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подбирает врагов для комнаты и расставляет их так, чтобы они не появились
|
||||
* вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов
|
||||
* берутся из правил уровня (rules). Возвращает массив — вызывающий код кладёт
|
||||
* его в room.enemies.
|
||||
*/
|
||||
function pickEnemyType(room: Room, rng: Rng, fastChance: number): EnemyType {
|
||||
if (room.type === 'boss') return 'boss';
|
||||
const roll = rng.next();
|
||||
// normal: до 0.4, fast: 0.4-0.6, charger: 0.6-0.75, tank: 0.75-0.88, shooter: 0.88-0.95, splitter: 0.95-1.0
|
||||
if (roll < 0.4) return 'normal';
|
||||
if (roll < 0.4 + fastChance * 0.7) return 'fast';
|
||||
if (roll < 0.7) return 'charger';
|
||||
if (roll < 0.85) return 'tank';
|
||||
if (roll < 0.95) return 'shooter';
|
||||
return 'splitter';
|
||||
}
|
||||
|
||||
export function spawnEnemies(
|
||||
room: Room,
|
||||
entryDir: Dir,
|
||||
playerX: number,
|
||||
playerY: number,
|
||||
rng: Rng,
|
||||
rules: LevelRules = DEFAULT_RULES,
|
||||
): Enemy[] {
|
||||
const enemies: Enemy[] = [];
|
||||
const er = rules.enemies;
|
||||
|
||||
const count =
|
||||
room.type === 'boss' ? 1 :
|
||||
room.type === 'treasure' || room.type === 'secret' ? 0 :
|
||||
Math.max(1, Math.round((SPAWN.normalMin + rng.int(0, SPAWN.normalExtra)) * er.densityMul));
|
||||
|
||||
const door = DOOR[entryDir];
|
||||
const doorX = OX + door.cx * TILE + TILE / 2;
|
||||
const doorY = OY + door.cy * TILE + TILE / 2;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const type: EnemyType = pickEnemyType(room, rng, er.fastChance);
|
||||
const mods = {
|
||||
hpMul: er.hpMul * (type === 'boss' ? er.bossHpMul : 1),
|
||||
speedMul: er.speedMul,
|
||||
};
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let ok = false;
|
||||
for (let tries = 0; tries < SPAWN.maxPlacementTries && !ok; tries++) {
|
||||
x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
|
||||
y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
|
||||
ok = isSpawnSpotClear(x, y, doorX, doorY, playerX, playerY, enemies);
|
||||
}
|
||||
if (!ok) continue;
|
||||
|
||||
enemies.push(new Enemy(x, y, type, mods));
|
||||
}
|
||||
|
||||
return enemies;
|
||||
}
|
||||
|
||||
const TREASURE_WEAPONS: WeaponId[] = ['shotgun', 'axe', 'staff', 'whip', 'bomb', 'boomerang', 'laser'];
|
||||
|
||||
export function spawnChest(room: Room, rng: Rng): Chest {
|
||||
const cx = OX + (COLS / 2) * TILE;
|
||||
const cy = OY + (ROWS / 2) * TILE;
|
||||
return new Chest(cx, cy);
|
||||
}
|
||||
|
||||
export function pickChestWeapon(rng: Rng): WeaponId {
|
||||
return rng.pick(TREASURE_WEAPONS);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Общие типы данных, на которые опирается вся игра. */
|
||||
|
||||
export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss' | 'secret';
|
||||
export type Dir = 'up' | 'down' | 'left' | 'right';
|
||||
export type CombatMode = 0 | 1; // MODE_RANGED | MODE_MELEE
|
||||
export type EnemyType = 'normal' | 'fast' | 'boss' | 'charger' | 'tank' | 'shooter' | 'splitter';
|
||||
export type ProjectileType = 'tear' | 'fireball' | 'bomb' | 'boomerang' | 'laser' | 'beam';
|
||||
|
||||
/** Прямоугольник (axis-aligned bounding box) для коллизий. */
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Какие из четырёх дверей есть у комнаты. */
|
||||
export interface Doors {
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
}
|
||||