Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7362a2ad8 | ||
|
|
58bf96e9d9 | ||
|
|
f4316431e0 |
@@ -0,0 +1,4 @@
|
|||||||
|
# Эти строки раскомментируй, если появятся ссылки на спонсорство
|
||||||
|
# github: [VovaManul]
|
||||||
|
# patreon: # username
|
||||||
|
# ko_fi: # username
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: Сообщить о баге
|
||||||
|
about: Что-то работает не так
|
||||||
|
title: '[BUG] '
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
<!-- Чёткое описание бага. -->
|
||||||
|
|
||||||
|
## Воспроизведение
|
||||||
|
|
||||||
|
1. Открыть `dist/index.html` (или `bun run dev`)
|
||||||
|
2. Выбрать уровень «…»
|
||||||
|
3. Перейти в комнату …
|
||||||
|
4. Использовать …
|
||||||
|
5. Видно, что …
|
||||||
|
|
||||||
|
## Ожидаемое поведение
|
||||||
|
|
||||||
|
<!-- Что должно было произойти. -->
|
||||||
|
|
||||||
|
## Скриншоты / видео
|
||||||
|
|
||||||
|
## Среда
|
||||||
|
|
||||||
|
- Браузер: Chrome/Firefox/Safari
|
||||||
|
- ОС: macOS/Windows/Linux
|
||||||
|
|
||||||
|
## Дополнительно
|
||||||
|
|
||||||
|
<!-- Логи консоли, seed забега и т.д. -->
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: Предложить идею
|
||||||
|
about: Новая фича, улучшение или изменение
|
||||||
|
title: '[FEATURE] '
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
<!-- Какая проблема решается? Чего не хватает? -->
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
<!-- Как это должно работать? Опиши желаемое поведение. -->
|
||||||
|
|
||||||
|
## Альтернативы
|
||||||
|
|
||||||
|
<!-- Какие ещё варианты рассматривал? -->
|
||||||
|
|
||||||
|
## Совместимость
|
||||||
|
|
||||||
|
- [ ] Меняет архитектуру «ядро ↔ рендер»
|
||||||
|
- [ ] Добавляет новую зависимость
|
||||||
|
- [ ] Ломает обратную совместимость
|
||||||
|
|
||||||
|
## Дополнительно
|
||||||
|
|
||||||
|
<!-- Скетчи, ссылки, примеры из других игр. -->
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## Описание
|
||||||
|
|
||||||
|
<!-- Краткое описание изменений. Что и зачем. -->
|
||||||
|
|
||||||
|
## Связанный issue
|
||||||
|
|
||||||
|
<!-- Fixes #номер -->
|
||||||
|
|
||||||
|
## Чеклист
|
||||||
|
|
||||||
|
- [ ] `bun run check` зелёный (typecheck + tests)
|
||||||
|
- [ ] Добавлены/обновлены тесты (если менялась логика)
|
||||||
|
- [ ] Проверено в браузере (если геймплей/рендер)
|
||||||
|
- [ ] Обновлена документация (если нужно)
|
||||||
|
- [ ] Нет `any` (кроме узких мест по согласованию)
|
||||||
|
- [ ] Нет `Math.random()` в `src/core/`
|
||||||
|
|
||||||
|
## Тип изменений
|
||||||
|
|
||||||
|
- [ ] Исправление бага
|
||||||
|
- [ ] Новая функциональность
|
||||||
|
- [ ] Рефакторинг
|
||||||
|
- [ ] Документация
|
||||||
|
- [ ] Другое
|
||||||
|
|
||||||
|
## Дополнительно
|
||||||
|
|
||||||
|
<!-- Любая дополнительная информация для ревьюера -->
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# AGENTS.md — как работать с этим проектом
|
||||||
|
|
||||||
|
Инструкции для ИИ-агентов **и** разработчика. Прочитай целиком перед правками.
|
||||||
|
Цель проекта — держать **рабочую, расширяемую базу** рогалика. Не ломать то, что
|
||||||
|
работает; добавлять — по правилам ниже.
|
||||||
|
|
||||||
|
## Что это
|
||||||
|
|
||||||
|
Top-down рогалик (в духе Binding of Isaac). Логика — чистый TypeScript
|
||||||
|
(`src/core/`), рендер — three.js в псевдо-3D (наклонная `PerspectiveCamera`, `src/render/`), сборка/тесты — Bun.
|
||||||
|
Обзор — [`README.md`](./README.md), детали — [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md),
|
||||||
|
рецепты доработки — [`docs/HOWTO.md`](./docs/HOWTO.md).
|
||||||
|
|
||||||
|
## 🔑 Золотые правила (нарушать = ломать архитектуру)
|
||||||
|
|
||||||
|
1. **Ядро без рендера и DOM.** В `src/core/**` НЕЛЬЗЯ импортировать `three`,
|
||||||
|
обращаться к `window`/`document`/`canvas`. Логика общается с миром только через
|
||||||
|
`InputState` (вход) и публичные поля `Game` (для чтения рендером).
|
||||||
|
2. **Рендер ничего не меняет в игре.** `src/render/**` только ЧИТАЕТ `Game` и
|
||||||
|
рисует. Любая мутация состояния из рендера — баг.
|
||||||
|
3. **Случайность только через `Rng`.** Никаких `Math.random()` в `core/`. Это
|
||||||
|
сохраняет детерминизм (тесты, отладка по seed).
|
||||||
|
4. **Время — в «шагах» (1/60 c), а не в кадрах.** Скорости — «пиксели/шаг»,
|
||||||
|
перезарядки — «шаги». Не двигай ничего в коде рендера или прямо в rAF.
|
||||||
|
5. **Числа — не в коде.** Константы движка (размеры, геометрия дверей, базовый
|
||||||
|
баланс) — в `src/config.ts`; параметры конкретного забега (размер карты, сила
|
||||||
|
врагов, HP игрока, seed) — в правилах уровня `src/core/rules.ts`. Не раскидывай
|
||||||
|
«магические числа» по логике.
|
||||||
|
6. **Освобождай ресурсы three.js.** Создаёшь геометрию/материал на сущность —
|
||||||
|
обеспечь `dispose()` при её удалении (см. `sync*`/`sweep` в `ThreeRenderer`).
|
||||||
|
7. **Комментарии и текст для игрока — по-русски**, как в существующем коде.
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev # дев-сервер + watch → http://localhost:3000
|
||||||
|
bun run build # прод-сборка в dist/
|
||||||
|
bun run typecheck # tsc --noEmit (строгий) — НЕ ловится при bun build!
|
||||||
|
bun test # юнит-тесты ядра
|
||||||
|
bun run check # typecheck + test
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `bun build` **не проверяет типы**. Поэтому `bun run typecheck` обязателен —
|
||||||
|
> именно отсутствие тайп-чека когда-то скрыло рантайм-регрессию.
|
||||||
|
|
||||||
|
## Definition of Done (для любой правки)
|
||||||
|
|
||||||
|
1. `bun run check` зелёный (типы + тесты).
|
||||||
|
2. Если менял логику — **добавил/обновил тест** в `tests/`.
|
||||||
|
3. Если менял геймплей/рендер — **проверил в браузере** (`bun run dev`, открыть
|
||||||
|
страницу, увидеть, что играется, в консоли нет ошибок). Юнит-тесты не видят
|
||||||
|
рендер — визуальную проверку не пропускать.
|
||||||
|
4. Обновил доки, если поменялось поведение или структура.
|
||||||
|
|
||||||
|
## Где что лежит (карта для навигации)
|
||||||
|
|
||||||
|
| Хочешь поменять… | Иди в… |
|
||||||
|
|---|---|
|
||||||
|
| константы движка (размер тайла/комнаты, геометрия дверей, базовый баланс) | `src/config.ts` |
|
||||||
|
| правила уровня / пресеты в меню (размер карты, сила врагов, HP, seed) | `src/core/rules.ts` |
|
||||||
|
| поведение за один шаг (движение, атака, ИИ, переходы) | `src/core/Game.ts` |
|
||||||
|
| данные сущности | `src/core/entities/*` |
|
||||||
|
| генерацию карты | `src/core/world/RoomMap.ts` |
|
||||||
|
| форму комнаты/тайлы | `src/core/world/tiles.ts`, `Room.ts` |
|
||||||
|
| коллизии | `src/core/systems/collision.ts` |
|
||||||
|
| расстановку врагов | `src/core/systems/spawner.ts` |
|
||||||
|
| как рисуется мир (псевдо-3D) | `src/render/ThreeRenderer.ts` |
|
||||||
|
| спрайты/текстуры (графика) | `src/render/assets.ts` (бриф на художку — `docs/ASSET_BRIEF.md`) |
|
||||||
|
| цвета/тинты/фон | `src/render/theme.ts` |
|
||||||
|
| HUD/миникарту | `src/render/HudOverlay.ts` |
|
||||||
|
| стартовое меню | `src/ui/StartMenu.ts` |
|
||||||
|
| раскладку клавиш | `src/input/KeyboardController.ts` |
|
||||||
|
| тайминг/цикл, поток меню↔игра | `src/engine/GameLoop.ts`, `src/main.ts` |
|
||||||
|
|
||||||
|
Пошаговые рецепты («добавить врага», «новое оружие», «тип комнаты», «сменить
|
||||||
|
рендер») — в [`docs/HOWTO.md`](./docs/HOWTO.md).
|
||||||
|
|
||||||
|
## Грабли, на которые уже наступали (не повторять)
|
||||||
|
|
||||||
|
- **Пустая карта.** `new RoomMap(rng)` ДОЛЖЕН генерировать карту в конструкторе.
|
||||||
|
Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте.
|
||||||
|
- **`OPP` направлений.** Противоположное к `up` — это `down`, к `left` — `right`.
|
||||||
|
Любая другая раскладка ломает встречные двери и связность карты.
|
||||||
|
- **Пол «выворачивает» перспективу.** Пол в псевдо-3D надо класть ПЛАШМЯ
|
||||||
|
(`flatMesh`, поворот −90° вокруг X). Без поворота он встаёт вертикально и вид
|
||||||
|
ломается. Спрайты/стены — `DoubleSide` (камера переворачивает Y, иначе грани отсекаются).
|
||||||
|
- **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`),
|
||||||
|
иначе они показываются в размер HiDPI-буфера.
|
||||||
|
- **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) —
|
||||||
|
она должна стать `cleared` сразу при входе, иначе двери не появятся.
|
||||||
|
|
||||||
|
## Стиль кода
|
||||||
|
|
||||||
|
- TypeScript strict, без `any` (кроме узких мест вроде `window as …` в `main.ts`).
|
||||||
|
- Маленькие чистые функции для логики; классы — для сущностей/состояния.
|
||||||
|
- Имена и комментарии осмысленные, по-русски. Комментарий объясняет «почему», а не «что».
|
||||||
|
- Перед коммитом — `bun run check`.
|
||||||
|
|
||||||
|
## Чего НЕ делать без явной просьбы
|
||||||
|
|
||||||
|
- Не добавлять тяжёлые зависимости (физдвижки, фреймворки). База намеренно лёгкая.
|
||||||
|
- Не переписывать архитектуру «ядро ↔ рендер».
|
||||||
|
- Не коммитить `dist/` и `node_modules/` (см. `.gitignore`).
|
||||||
|
- Не превращать игру в полноценное 3D, пока этого не попросили (рендер для этого
|
||||||
|
готов — он изолирован, — но это отдельная большая задача).
|
||||||
@@ -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.
|
||||||
+24
@@ -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,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 0,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dungeon-crawl",
|
"name": "dungeon-crawl",
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
|
Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
|
||||||
зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
|
зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
|
||||||
|
|
||||||
1. Раз в кадр опрашиваем ввод (`controller.poll()`).
|
1. Раз в кадр опрашиваем источник ввода (`controller.poll()`). Цикл знает только
|
||||||
|
маленький интерфейс `poll(): InputState`, а не конкретную клавиатуру.
|
||||||
2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`.
|
2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`.
|
||||||
3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными
|
3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными
|
||||||
порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
|
порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export const OY = 80; // отступ комнаты сверху (м
|
|||||||
export const FIXED_FPS = 60;
|
export const FIXED_FPS = 60;
|
||||||
export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг
|
export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг
|
||||||
|
|
||||||
|
export const GAME_LOOP = {
|
||||||
|
maxFrameTimeSec: 0.25, // не «отыгрывать» долгие паузы (фон/таб)
|
||||||
|
maxStepsPerFrame: 5, // защита от «спирали смерти» при лагах
|
||||||
|
};
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// Типы тайлов
|
// Типы тайлов
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -88,6 +93,7 @@ export const PLAYER = {
|
|||||||
speed: 3.2, // px за шаг
|
speed: 3.2, // px за шаг
|
||||||
maxHp: 6,
|
maxHp: 6,
|
||||||
invFrames: 60, // неуязвимость после удара, в шагах
|
invFrames: 60, // неуязвимость после удара, в шагах
|
||||||
|
entryInvFrames: 20, // короткая неуязвимость при входе в комнату, в шагах
|
||||||
rangedCooldown: 10, // перезарядка выстрела, в шагах
|
rangedCooldown: 10, // перезарядка выстрела, в шагах
|
||||||
meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
|
meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
|
||||||
transitionLock: 15, // блок повторного перехода между комнатами, в шагах
|
transitionLock: 15, // блок повторного перехода между комнатами, в шагах
|
||||||
@@ -137,6 +143,7 @@ export const SPAWN = {
|
|||||||
minDistFromDoor: 180, // не спавнить ближе к двери входа
|
minDistFromDoor: 180, // не спавнить ближе к двери входа
|
||||||
minDistFromPlayer: 150,
|
minDistFromPlayer: 150,
|
||||||
minDistBetween: 60,
|
minDistBetween: 60,
|
||||||
|
maxPlacementTries: 100,
|
||||||
treasureChance: 0.12, // шанс комнаты-сокровищницы
|
treasureChance: 0.12, // шанс комнаты-сокровищницы
|
||||||
bossChance: 0.2, // шанс назначить комнату боссом
|
bossChance: 0.2, // шанс назначить комнату боссом
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -128,7 +128,7 @@ export class Game {
|
|||||||
const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
|
const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
|
||||||
this.player.place(px, py);
|
this.player.place(px, py);
|
||||||
this.player.facing = fromDir;
|
this.player.facing = fromDir;
|
||||||
this.player.invTimer = 20; // короткая неуязвимость на входе
|
this.player.invTimer = PLAYER.entryInvFrames;
|
||||||
this.player.transCD = PLAYER.transitionLock;
|
this.player.transCD = PLAYER.transitionLock;
|
||||||
|
|
||||||
this.meleeSwing = null;
|
this.meleeSwing = null;
|
||||||
|
|||||||
+23
-3
@@ -12,9 +12,9 @@ export class Rng {
|
|||||||
private state: number;
|
private state: number;
|
||||||
|
|
||||||
/** Без seed — случайный старт; с seed — детерминированная цепочка. */
|
/** Без seed — случайный старт; с seed — детерминированная цепочка. */
|
||||||
constructor(seed?: number) {
|
constructor(seed: number = autoSeed()) {
|
||||||
// 0 — валидный seed, поэтому проверяем именно на undefined.
|
// 0 — валидный seed; авто-seed включается только когда аргумент не передан.
|
||||||
this.state = (seed === undefined ? (Math.random() * 2 ** 32) >>> 0 : seed) >>> 0;
|
this.state = seed >>> 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
|
/** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
|
||||||
@@ -43,6 +43,7 @@ export class Rng {
|
|||||||
|
|
||||||
/** Случайный элемент массива. */
|
/** Случайный элемент массива. */
|
||||||
pick<T>(arr: readonly T[]): T {
|
pick<T>(arr: readonly T[]): T {
|
||||||
|
if (arr.length === 0) throw new Error('Rng.pick: пустой массив');
|
||||||
return arr[this.int(0, arr.length - 1)];
|
return arr[this.int(0, arr.length - 1)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,3 +56,22 @@ export class Rng {
|
|||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AUTO_SEED_STEP = 0x9e3779b9;
|
||||||
|
let autoSeedCounter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed для нового нефиксированного забега. Энтропия берётся только внутри Rng:
|
||||||
|
* остальная игровая логика по-прежнему получает все случайные числа через next().
|
||||||
|
*/
|
||||||
|
function autoSeed(): number {
|
||||||
|
autoSeedCounter = (autoSeedCounter + AUTO_SEED_STEP) >>> 0;
|
||||||
|
let seed = Date.now() >>> 0;
|
||||||
|
const crypto = globalThis.crypto;
|
||||||
|
if (crypto?.getRandomValues) {
|
||||||
|
const value = new Uint32Array(1);
|
||||||
|
crypto.getRandomValues(value);
|
||||||
|
seed ^= value[0];
|
||||||
|
}
|
||||||
|
return (seed ^ autoSeedCounter) >>> 0;
|
||||||
|
}
|
||||||
|
|||||||
+20
-10
@@ -6,6 +6,23 @@ import type { Dir, EnemyType } from '../types';
|
|||||||
import type { Rng } from '../rng';
|
import type { Rng } from '../rng';
|
||||||
import { DEFAULT_RULES, type LevelRules } from '../rules';
|
import { DEFAULT_RULES, type LevelRules } from '../rules';
|
||||||
|
|
||||||
|
function isSpawnSpotClear(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
doorX: number,
|
||||||
|
doorY: number,
|
||||||
|
playerX: number,
|
||||||
|
playerY: number,
|
||||||
|
enemies: readonly Enemy[],
|
||||||
|
): boolean {
|
||||||
|
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) return false;
|
||||||
|
if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) return false;
|
||||||
|
for (const e of enemies) {
|
||||||
|
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Подбирает врагов для комнаты и расставляет их так, чтобы они не появились
|
* Подбирает врагов для комнаты и расставляет их так, чтобы они не появились
|
||||||
* вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов
|
* вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов
|
||||||
@@ -43,19 +60,12 @@ export function spawnEnemies(
|
|||||||
let x = 0;
|
let x = 0;
|
||||||
let y = 0;
|
let y = 0;
|
||||||
let ok = false;
|
let ok = false;
|
||||||
for (let tries = 0; tries < 100 && !ok; tries++) {
|
for (let tries = 0; tries < SPAWN.maxPlacementTries && !ok; tries++) {
|
||||||
x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
|
x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
|
||||||
y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
|
y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
|
||||||
ok = true;
|
ok = isSpawnSpotClear(x, y, doorX, doorY, playerX, playerY, enemies);
|
||||||
|
|
||||||
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) ok = false;
|
|
||||||
else if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) ok = false;
|
|
||||||
else {
|
|
||||||
for (const e of enemies) {
|
|
||||||
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) { ok = false; break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!ok) continue;
|
||||||
|
|
||||||
enemies.push(new Enemy(x, y, type, mods));
|
enemies.push(new Enemy(x, y, type, mods));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FIXED_DT } from '../config';
|
import { FIXED_DT, GAME_LOOP } from '../config';
|
||||||
import type { Game } from '../core/Game';
|
import type { Game } from '../core/Game';
|
||||||
import type { KeyboardController } from '../input/KeyboardController';
|
import type { InputSource } from '../input/InputState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Игровой цикл с ФИКСИРОВАННЫМ шагом.
|
* Игровой цикл с ФИКСИРОВАННЫМ шагом.
|
||||||
@@ -15,11 +15,10 @@ export class GameLoop {
|
|||||||
private last = 0;
|
private last = 0;
|
||||||
private rafId = 0;
|
private rafId = 0;
|
||||||
private running = false;
|
private running = false;
|
||||||
private readonly maxSteps = 5; // защита от «спирали смерти» при лагах
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly game: Game,
|
private readonly game: Game,
|
||||||
private readonly controller: KeyboardController,
|
private readonly controller: InputSource,
|
||||||
private readonly onRender: (alpha: number) => void,
|
private readonly onRender: (alpha: number) => void,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -31,16 +30,19 @@ export class GameLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
if (!this.running) return;
|
||||||
this.running = false;
|
this.running = false;
|
||||||
cancelAnimationFrame(this.rafId);
|
cancelAnimationFrame(this.rafId);
|
||||||
|
this.rafId = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private frame = (now: number): void => {
|
private frame = (now: number): void => {
|
||||||
|
if (!this.running) return;
|
||||||
this.rafId = requestAnimationFrame(this.frame);
|
this.rafId = requestAnimationFrame(this.frame);
|
||||||
|
|
||||||
let frameTime = (now - this.last) / 1000;
|
let frameTime = (now - this.last) / 1000;
|
||||||
this.last = now;
|
this.last = now;
|
||||||
if (frameTime > 0.25) frameTime = 0.25; // не «отыгрывать» долгие паузы (фон/таб)
|
if (frameTime > GAME_LOOP.maxFrameTimeSec) frameTime = GAME_LOOP.maxFrameTimeSec;
|
||||||
|
|
||||||
// Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр.
|
// Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр.
|
||||||
const input = this.controller.poll();
|
const input = this.controller.poll();
|
||||||
@@ -48,12 +50,12 @@ export class GameLoop {
|
|||||||
|
|
||||||
this.accumulator += frameTime;
|
this.accumulator += frameTime;
|
||||||
let steps = 0;
|
let steps = 0;
|
||||||
while (this.accumulator >= FIXED_DT && steps < this.maxSteps) {
|
while (this.accumulator >= FIXED_DT && steps < GAME_LOOP.maxStepsPerFrame) {
|
||||||
this.game.step(input);
|
this.game.step(input);
|
||||||
this.accumulator -= FIXED_DT;
|
this.accumulator -= FIXED_DT;
|
||||||
steps++;
|
steps++;
|
||||||
}
|
}
|
||||||
if (steps === this.maxSteps) this.accumulator = 0; // отстали — ресинхронизируемся
|
if (steps === GAME_LOOP.maxStepsPerFrame) this.accumulator = 0; // отстали — ресинхронизируемся
|
||||||
|
|
||||||
const alpha = this.accumulator / FIXED_DT;
|
const alpha = this.accumulator / FIXED_DT;
|
||||||
this.onRender(alpha);
|
this.onRender(alpha);
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ export interface InputState {
|
|||||||
restart: boolean; // рестарт на экране конца игры (однократно)
|
restart: boolean; // рестарт на экране конца игры (однократно)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */
|
||||||
|
export interface InputSource {
|
||||||
|
poll(): InputState;
|
||||||
|
}
|
||||||
|
|
||||||
/** Нейтральный снимок — ничего не нажато. */
|
/** Нейтральный снимок — ничего не нажато. */
|
||||||
export function emptyInput(): InputState {
|
export function emptyInput(): InputState {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { InputState } from './InputState';
|
import type { InputSource, InputState } from './InputState';
|
||||||
import type { Dir } from '../core/types';
|
import type { Dir } from '../core/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +14,7 @@ import type { Dir } from '../core/types';
|
|||||||
* (смена оружия/рестарт). Раз в кадр вызывается poll(), который собирает
|
* (смена оружия/рестарт). Раз в кадр вызывается poll(), который собирает
|
||||||
* InputState и сбрасывает однократные флаги.
|
* InputState и сбрасывает однократные флаги.
|
||||||
*/
|
*/
|
||||||
export class KeyboardController {
|
export class KeyboardController implements InputSource {
|
||||||
private held = new Set<string>();
|
private held = new Set<string>();
|
||||||
private toggleWeaponEdge = false;
|
private toggleWeaponEdge = false;
|
||||||
private restartEdge = false;
|
private restartEdge = false;
|
||||||
|
|||||||
@@ -41,4 +41,21 @@ describe('Rng', () => {
|
|||||||
const shuffled = r.shuffle([...arr]);
|
const shuffled = r.shuffle([...arr]);
|
||||||
expect([...shuffled].sort()).toEqual(arr);
|
expect([...shuffled].sort()).toEqual(arr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('авто-seed не вызывает Math.random()', () => {
|
||||||
|
const math = Math as Math & { random: () => number };
|
||||||
|
const original = math.random;
|
||||||
|
math.random = () => { throw new Error('Math.random вызван'); };
|
||||||
|
try {
|
||||||
|
const r = new Rng();
|
||||||
|
expect(r.next()).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(r.next()).toBeLessThan(1);
|
||||||
|
} finally {
|
||||||
|
math.random = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pick явно ругается на пустой массив', () => {
|
||||||
|
expect(() => new Rng(1).pick([])).toThrow('пустой массив');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import { Room } from '../src/core/world/Room';
|
|||||||
import { Rng } from '../src/core/rng';
|
import { Rng } from '../src/core/rng';
|
||||||
import { OX, OY, TILE, DOOR, SPAWN } from '../src/config';
|
import { OX, OY, TILE, DOOR, SPAWN } from '../src/config';
|
||||||
import { dist } from '../src/core/util';
|
import { dist } from '../src/core/util';
|
||||||
|
import { DEFAULT_RULES, type LevelRules } from '../src/core/rules';
|
||||||
|
|
||||||
|
const denseRules: LevelRules = {
|
||||||
|
...DEFAULT_RULES,
|
||||||
|
id: 'dense-test',
|
||||||
|
name: 'Тестовая тесная комната',
|
||||||
|
description: 'Много врагов, чтобы проверить отказ от плохих позиций спавна.',
|
||||||
|
map: { ...DEFAULT_RULES.map },
|
||||||
|
player: { ...DEFAULT_RULES.player },
|
||||||
|
enemies: { ...DEFAULT_RULES.enemies, densityMul: 40 },
|
||||||
|
};
|
||||||
|
|
||||||
describe('spawner', () => {
|
describe('spawner', () => {
|
||||||
it('в обычной комнате врагов в ожидаемом диапазоне', () => {
|
it('в обычной комнате врагов в ожидаемом диапазоне', () => {
|
||||||
@@ -40,4 +51,24 @@ describe('spawner', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('РЕГРЕССИЯ: при переполнении не ставит врага в последнюю плохую точку', () => {
|
||||||
|
const room = new Room(1, 0, 'normal');
|
||||||
|
const playerX = OX + 7 * TILE;
|
||||||
|
const playerY = OY + 5 * TILE;
|
||||||
|
const door = DOOR.left;
|
||||||
|
const doorX = OX + door.cx * TILE + TILE / 2;
|
||||||
|
const doorY = OY + door.cy * TILE + TILE / 2;
|
||||||
|
const enemies = spawnEnemies(room, 'left', playerX, playerY, new Rng(12), denseRules);
|
||||||
|
|
||||||
|
expect(enemies.length).toBeGreaterThan(0);
|
||||||
|
for (let i = 0; i < enemies.length; i++) {
|
||||||
|
const e = enemies[i];
|
||||||
|
expect(dist(e.x, e.y, doorX, doorY)).toBeGreaterThanOrEqual(SPAWN.minDistFromDoor);
|
||||||
|
expect(dist(e.x, e.y, playerX, playerY)).toBeGreaterThanOrEqual(SPAWN.minDistFromPlayer);
|
||||||
|
for (let j = i + 1; j < enemies.length; j++) {
|
||||||
|
expect(dist(e.x, e.y, enemies[j].x, enemies[j].y)).toBeGreaterThanOrEqual(SPAWN.minDistBetween);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user