Author SHA1 Message Date
mayatnikov b7362a2ad8 refactor: tighten core seams and spawning 2026-06-18 18:34:45 +03:00
Manul 58bf96e9d9 Merge pull request #2 from halofourteen/round2/pseudo-3d
Раунд 2: псевдо-3D + дизайнерский арт, сердечки/иконки, меню («Биндим Фигняшку»)
2026-06-18 16:24:33 +03:00
Volodia f4316431e0 add community health files
- LICENSE (MIT)
- CONTRIBUTING.md
- CODE_OF_CONDUCT.md
- SECURITY.md
- .github/PULL_REQUEST_TEMPLATE.md
- .github/ISSUE_TEMPLATE/bug_report.md
- .github/ISSUE_TEMPLATE/feature_request.md
- .github/FUNDING.yml
2026-06-18 14:33:47 +03:00
20 changed files with 464 additions and 24 deletions
+4
View File
@@ -0,0 +1,4 @@
# Эти строки раскомментируй, если появятся ссылки на спонсорство
# github: [VovaManul]
# patreon: # username
# ko_fi: # username
+34
View File
@@ -0,0 +1,34 @@
---
name: Сообщить о баге
about: Что-то работает не так
title: '[BUG] '
labels: bug
assignees: ''
---
## Описание
<!-- Чёткое описание бага. -->
## Воспроизведение
1. Открыть `dist/index.html` (или `bun run dev`)
2. Выбрать уровень «…»
3. Перейти в комнату …
4. Использовать …
5. Видно, что …
## Ожидаемое поведение
<!-- Что должно было произойти. -->
## Скриншоты / видео
## Среда
- Браузер: Chrome/Firefox/Safari
- ОС: macOS/Windows/Linux
## Дополнительно
<!-- Логи консоли, seed забега и т.д. -->
+29
View File
@@ -0,0 +1,29 @@
---
name: Предложить идею
about: Новая фича, улучшение или изменение
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
## Проблема
<!-- Какая проблема решается? Чего не хватает? -->
## Решение
<!-- Как это должно работать? Опиши желаемое поведение. -->
## Альтернативы
<!-- Какие ещё варианты рассматривал? -->
## Совместимость
- [ ] Меняет архитектуру «ядро ↔ рендер»
- [ ] Добавляет новую зависимость
- [ ] Ломает обратную совместимость
## Дополнительно
<!-- Скетчи, ссылки, примеры из других игр. -->
+28
View File
@@ -0,0 +1,28 @@
## Описание
<!-- Краткое описание изменений. Что и зачем. -->
## Связанный issue
<!-- Fixes #номер -->
## Чеклист
- [ ] `bun run check` зелёный (typecheck + tests)
- [ ] Добавлены/обновлены тесты (если менялась логика)
- [ ] Проверено в браузере (если геймплей/рендер)
- [ ] Обновлена документация (если нужно)
- [ ] Нет `any` (кроме узких мест по согласованию)
- [ ] Нет `Math.random()` в `src/core/`
## Тип изменений
- [ ] Исправление бага
- [ ] Новая функциональность
- [ ] Рефакторинг
- [ ] Документация
- [ ] Другое
## Дополнительно
<!-- Любая дополнительная информация для ревьюера -->
+105
View File
@@ -0,0 +1,105 @@
# AGENTS.md — как работать с этим проектом
Инструкции для ИИ-агентов **и** разработчика. Прочитай целиком перед правками.
Цель проекта — держать **рабочую, расширяемую базу** рогалика. Не ломать то, что
работает; добавлять — по правилам ниже.
## Что это
Top-down рогалик (в духе Binding of Isaac). Логика — чистый TypeScript
(`src/core/`), рендер — three.js в псевдо-3D (наклонная `PerspectiveCamera`, `src/render/`), сборка/тесты — Bun.
Обзор — [`README.md`](./README.md), детали — [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md),
рецепты доработки — [`docs/HOWTO.md`](./docs/HOWTO.md).
## 🔑 Золотые правила (нарушать = ломать архитектуру)
1. **Ядро без рендера и DOM.** В `src/core/**` НЕЛЬЗЯ импортировать `three`,
обращаться к `window`/`document`/`canvas`. Логика общается с миром только через
`InputState` (вход) и публичные поля `Game` (для чтения рендером).
2. **Рендер ничего не меняет в игре.** `src/render/**` только ЧИТАЕТ `Game` и
рисует. Любая мутация состояния из рендера — баг.
3. **Случайность только через `Rng`.** Никаких `Math.random()` в `core/`. Это
сохраняет детерминизм (тесты, отладка по seed).
4. **Время — в «шагах» (1/60 c), а не в кадрах.** Скорости — «пиксели/шаг»,
перезарядки — «шаги». Не двигай ничего в коде рендера или прямо в rAF.
5. **Числа — не в коде.** Константы движка (размеры, геометрия дверей, базовый
баланс) — в `src/config.ts`; параметры конкретного забега (размер карты, сила
врагов, HP игрока, seed) — в правилах уровня `src/core/rules.ts`. Не раскидывай
«магические числа» по логике.
6. **Освобождай ресурсы three.js.** Создаёшь геометрию/материал на сущность —
обеспечь `dispose()` при её удалении (см. `sync*`/`sweep` в `ThreeRenderer`).
7. **Комментарии и текст для игрока — по-русски**, как в существующем коде.
## Команды
```bash
bun run dev # дев-сервер + watch → http://localhost:3000
bun run build # прод-сборка в dist/
bun run typecheck # tsc --noEmit (строгий) — НЕ ловится при bun build!
bun test # юнит-тесты ядра
bun run check # typecheck + test
```
> ⚠️ `bun build` **не проверяет типы**. Поэтому `bun run typecheck` обязателен —
> именно отсутствие тайп-чека когда-то скрыло рантайм-регрессию.
## Definition of Done (для любой правки)
1. `bun run check` зелёный (типы + тесты).
2. Если менял логику — **добавил/обновил тест** в `tests/`.
3. Если менял геймплей/рендер — **проверил в браузере** (`bun run dev`, открыть
страницу, увидеть, что играется, в консоли нет ошибок). Юнит-тесты не видят
рендер — визуальную проверку не пропускать.
4. Обновил доки, если поменялось поведение или структура.
## Где что лежит (карта для навигации)
| Хочешь поменять… | Иди в… |
|---|---|
| константы движка (размер тайла/комнаты, геометрия дверей, базовый баланс) | `src/config.ts` |
| правила уровня / пресеты в меню (размер карты, сила врагов, HP, seed) | `src/core/rules.ts` |
| поведение за один шаг (движение, атака, ИИ, переходы) | `src/core/Game.ts` |
| данные сущности | `src/core/entities/*` |
| генерацию карты | `src/core/world/RoomMap.ts` |
| форму комнаты/тайлы | `src/core/world/tiles.ts`, `Room.ts` |
| коллизии | `src/core/systems/collision.ts` |
| расстановку врагов | `src/core/systems/spawner.ts` |
| как рисуется мир (псевдо-3D) | `src/render/ThreeRenderer.ts` |
| спрайты/текстуры (графика) | `src/render/assets.ts` (бриф на художку — `docs/ASSET_BRIEF.md`) |
| цвета/тинты/фон | `src/render/theme.ts` |
| HUD/миникарту | `src/render/HudOverlay.ts` |
| стартовое меню | `src/ui/StartMenu.ts` |
| раскладку клавиш | `src/input/KeyboardController.ts` |
| тайминг/цикл, поток меню↔игра | `src/engine/GameLoop.ts`, `src/main.ts` |
Пошаговые рецепты («добавить врага», «новое оружие», «тип комнаты», «сменить
рендер») — в [`docs/HOWTO.md`](./docs/HOWTO.md).
## Грабли, на которые уже наступали (не повторять)
- **Пустая карта.** `new RoomMap(rng)` ДОЛЖЕН генерировать карту в конструкторе.
Если карта пустая — `curRoom` будет `undefined` и всё упадёт на старте.
- **`OPP` направлений.** Противоположное к `up` — это `down`, к `left``right`.
Любая другая раскладка ломает встречные двери и связность карты.
- **Пол «выворачивает» перспективу.** Пол в псевдо-3D надо класть ПЛАШМЯ
(`flatMesh`, поворот −90° вокруг X). Без поворота он встаёт вертикально и вид
ломается. Спрайты/стены — `DoubleSide` (камера переворачивает Y, иначе грани отсекаются).
- **Canvas вылезает за рамки.** Холстам нужен CSS-размер (`width/height:100%`),
иначе они показываются в размер HiDPI-буфера.
- **Комната без врагов не открывается.** Если в комнате 0 врагов (сокровищница) —
она должна стать `cleared` сразу при входе, иначе двери не появятся.
## Стиль кода
- TypeScript strict, без `any` (кроме узких мест вроде `window as …` в `main.ts`).
- Маленькие чистые функции для логики; классы — для сущностей/состояния.
- Имена и комментарии осмысленные, по-русски. Комментарий объясняет «почему», а не «что».
- Перед коммитом — `bun run check`.
## Чего НЕ делать без явной просьбы
- Не добавлять тяжёлые зависимости (физдвижки, фреймворки). База намеренно лёгкая.
- Не переписывать архитектуру «ядро ↔ рендер».
- Не коммитить `dist/` и `node_modules/` (см. `.gitignore`).
- Не превращать игру в полноценное 3D, пока этого не попросили (рендер для этого
готов — он изолирован, — но это отдельная большая задача).
+52
View File
@@ -0,0 +1,52 @@
# Кодекс поведения
## Наши обязательства
Мы, как участники и лидеры этого проекта, обязуемся создать гостеприимную
и безопасную среду для всех, независимо от возраста, телосложения,
видимых или невидимых физических особенностей, этнической принадлежности,
половых признаков, уровня опыта, образования, социально-экономического
статуса, национальности, внешности, расы, религии или сексуальной
идентичности и ориентации.
Мы обязуемся действовать и взаимодействовать так, чтобы способствовать
открытому, доброжелательному, инклюзивному и здоровому сообществу.
## Приемлемое поведение
Примеры поведения, способствующего созданию положительной среды:
- Проявление доброты и эмпатии по отношению к другим участникам
- Уважение к разным точкам зрения и опыту
- Конструктивная обратная связь
- Признание своих ошибок и извлечение уроков
## Неприемлемое поведение
- Использование сексуализированного языка или образов
- Троллинг, оскорбительные/уничижительные комментарии
- Преследование в любой форме
- Публикация личной информации без явного разрешения
- Другое поведение, которое можно обоснованно считать неуместным
## Ответственность
Лидеры проекта отвечают за разъяснение стандартов поведения и будут
предпринимать соответствующие и справедливые меры в ответ на любое
неприемлемое поведение.
## Сфера действия
Этот Кодекс поведения применяется во всех сообществах проекта, а также
в публичных местах, когда человек представляет проект.
## Enforcement
О случаях неприемлемого поведения можно сообщать лидерам проекта.
Все жалобы будут рассмотрены и приведут к ответным мерам, которые
сочтут необходимыми и соответствующими обстоятельствам.
## Атрибуция
Этот Кодекс поведения адаптирован из [Contributor Covenant](https://www.contributor-covenant.org),
версия 2.1.
+49
View File
@@ -0,0 +1,49 @@
# Как вносить изменения
Спасибо, что хочешь улучшить проект! Это живая база рогалика, и любая помощь
пригодится. Пожалуйста, следуй правилам ниже — они сохраняют архитектуру.
## Прежде чем писать код
1. Прочитай [`CLAUDE.md`](./CLAUDE.md) — там золотые правила архитектуры.
2. Почитай [`docs/HOWTO.md`](./docs/HOWTO.md) — там рецепты для типовых задач.
3. Посмотри [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) — пойми потоки данных.
## Процесс
1. Создай issue (баг или фича) — чтобы обсудить до начала работы.
2. Форкни репозиторий и создай ветку от `main`:
```bash
git checkout -b feature/что-делаешь
```
3. Пиши код, следуя стилю проекта:
- TypeScript strict, без `any`
- Комментарии по-русски, объясняют «почему», а не «что»
- Маленькие чистые функции; классы — для сущностей/состояния
4. Убедись, что всё зелёное:
```bash
bun run check # typecheck + tests
```
5. Если менял логику — **добавь/обнови тест** в `tests/`.
6. Если менял геймплей/рендер — **проверь в браузере** (`bun run dev`).
7. Обнови доки, если поменялось поведение или структура.
8. Создай Pull Request с описанием изменений.
## Золотые правила (нарушать = ломать)
1. **Ядро без рендера и DOM.** В `src/core/` нельзя импортировать `three`,
обращаться к `window`/`document`/`canvas`.
2. **Рендер ничего не меняет в игре.** `src/render/` только читает и рисует.
3. **Случайность только через `Rng`.** Никаких `Math.random()` в `core/`.
4. **Время — в шагах (1/60 с).** Не двигай ничего в rAF или рендере.
5. **Числа — не в коде.** Константы — в `src/config.ts`, параметры забега —
в `src/core/rules.ts`.
6. **Освобождай ресурсы three.js** — `dispose()` геометрию/материалы при удалении.
7. **Перед коммитом — `bun run check`.**
## Чего не делать
- Не добавлять тяжёлые зависимости (физдвижки, фреймворки).
- Не переписывать архитектуру «ядро ↔ рендер».
- Не коммитить `dist/` и `node_modules/`.
- Не превращать игру в полноценное 3D без обсуждения.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 VovaManul
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24
View File
@@ -0,0 +1,24 @@
# Политика безопасности
## Поддерживаемые версии
Активно разрабатывается только `main`-ветка. Стабильных релизов пока нет.
| Версия | Поддержка |
|---|---|
| main (активная разработка) | ✅ |
| Другие ветки | ❌ |
## Сообщить об уязвимости
Это локальная однопользовательская игра на Canvas/WebGL. Уязвимости
безопасности маловероятны, но если найдёшь что-то серьёзное:
- **Не создавай публичный issue.**
- Напиши в [issues](https://github.com/VovaManul/Binding-Fignyaac/issues)
с пометкой `[SECURITY]` в заголовке — репозиторий приватный/маленький,
это безопасно.
По умолчанию: игра не принимает пользовательский ввод, кроме клавиатуры,
и не выполняет удалённый код. Риск сводится к XSS через `dist/index.html`
при сборке — убедись, что билд чистый (`bun run build` из свежего клона).
+1
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "dungeon-crawl", "name": "dungeon-crawl",
+2 -1
View File
@@ -37,7 +37,8 @@
Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь: зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
1. Раз в кадр опрашиваем ввод (`controller.poll()`). 1. Раз в кадр опрашиваем источник ввода (`controller.poll()`). Цикл знает только
маленький интерфейс `poll(): InputState`, а не конкретную клавиатуру.
2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`. 2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`.
3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными 3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными
порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с. порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
+7
View File
@@ -28,6 +28,11 @@ export const OY = 80; // отступ комнаты сверху (м
export const FIXED_FPS = 60; export const FIXED_FPS = 60;
export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг export const FIXED_DT = 1 / FIXED_FPS; // секунд на шаг
export const GAME_LOOP = {
maxFrameTimeSec: 0.25, // не «отыгрывать» долгие паузы (фон/таб)
maxStepsPerFrame: 5, // защита от «спирали смерти» при лагах
};
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Типы тайлов // Типы тайлов
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
@@ -88,6 +93,7 @@ export const PLAYER = {
speed: 3.2, // px за шаг speed: 3.2, // px за шаг
maxHp: 6, maxHp: 6,
invFrames: 60, // неуязвимость после удара, в шагах invFrames: 60, // неуязвимость после удара, в шагах
entryInvFrames: 20, // короткая неуязвимость при входе в комнату, в шагах
rangedCooldown: 10, // перезарядка выстрела, в шагах rangedCooldown: 10, // перезарядка выстрела, в шагах
meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
transitionLock: 15, // блок повторного перехода между комнатами, в шагах transitionLock: 15, // блок повторного перехода между комнатами, в шагах
@@ -137,6 +143,7 @@ export const SPAWN = {
minDistFromDoor: 180, // не спавнить ближе к двери входа minDistFromDoor: 180, // не спавнить ближе к двери входа
minDistFromPlayer: 150, minDistFromPlayer: 150,
minDistBetween: 60, minDistBetween: 60,
maxPlacementTries: 100,
treasureChance: 0.12, // шанс комнаты-сокровищницы treasureChance: 0.12, // шанс комнаты-сокровищницы
bossChance: 0.2, // шанс назначить комнату боссом bossChance: 0.2, // шанс назначить комнату боссом
}; };
+1 -1
View File
@@ -128,7 +128,7 @@ export class Game {
const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE; const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
this.player.place(px, py); this.player.place(px, py);
this.player.facing = fromDir; this.player.facing = fromDir;
this.player.invTimer = 20; // короткая неуязвимость на входе this.player.invTimer = PLAYER.entryInvFrames;
this.player.transCD = PLAYER.transitionLock; this.player.transCD = PLAYER.transitionLock;
this.meleeSwing = null; this.meleeSwing = null;
+23 -3
View File
@@ -12,9 +12,9 @@ export class Rng {
private state: number; private state: number;
/** Без seed — случайный старт; с seed — детерминированная цепочка. */ /** Без seed — случайный старт; с seed — детерминированная цепочка. */
constructor(seed?: number) { constructor(seed: number = autoSeed()) {
// 0 — валидный seed, поэтому проверяем именно на undefined. // 0 — валидный seed; авто-seed включается только когда аргумент не передан.
this.state = (seed === undefined ? (Math.random() * 2 ** 32) >>> 0 : seed) >>> 0; this.state = seed >>> 0;
} }
/** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */ /** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
@@ -43,6 +43,7 @@ export class Rng {
/** Случайный элемент массива. */ /** Случайный элемент массива. */
pick<T>(arr: readonly T[]): T { pick<T>(arr: readonly T[]): T {
if (arr.length === 0) throw new Error('Rng.pick: пустой массив');
return arr[this.int(0, arr.length - 1)]; return arr[this.int(0, arr.length - 1)];
} }
@@ -55,3 +56,22 @@ export class Rng {
return arr; return arr;
} }
} }
const AUTO_SEED_STEP = 0x9e3779b9;
let autoSeedCounter = 0;
/**
* Seed для нового нефиксированного забега. Энтропия берётся только внутри Rng:
* остальная игровая логика по-прежнему получает все случайные числа через next().
*/
function autoSeed(): number {
autoSeedCounter = (autoSeedCounter + AUTO_SEED_STEP) >>> 0;
let seed = Date.now() >>> 0;
const crypto = globalThis.crypto;
if (crypto?.getRandomValues) {
const value = new Uint32Array(1);
crypto.getRandomValues(value);
seed ^= value[0];
}
return (seed ^ autoSeedCounter) >>> 0;
}
+20 -10
View File
@@ -6,6 +6,23 @@ import type { Dir, EnemyType } from '../types';
import type { Rng } from '../rng'; import type { Rng } from '../rng';
import { DEFAULT_RULES, type LevelRules } from '../rules'; import { DEFAULT_RULES, type LevelRules } from '../rules';
function isSpawnSpotClear(
x: number,
y: number,
doorX: number,
doorY: number,
playerX: number,
playerY: number,
enemies: readonly Enemy[],
): boolean {
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) return false;
if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) return false;
for (const e of enemies) {
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) return false;
}
return true;
}
/** /**
* Подбирает врагов для комнаты и расставляет их так, чтобы они не появились * Подбирает врагов для комнаты и расставляет их так, чтобы они не появились
* вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов * вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов
@@ -43,19 +60,12 @@ export function spawnEnemies(
let x = 0; let x = 0;
let y = 0; let y = 0;
let ok = false; let ok = false;
for (let tries = 0; tries < 100 && !ok; tries++) { for (let tries = 0; tries < SPAWN.maxPlacementTries && !ok; tries++) {
x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE; x = OX + 2 * TILE + rng.float(0, COLS - 4) * TILE;
y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE; y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
ok = true; ok = isSpawnSpotClear(x, y, doorX, doorY, playerX, playerY, enemies);
if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) ok = false;
else if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) ok = false;
else {
for (const e of enemies) {
if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) { ok = false; break; }
}
}
} }
if (!ok) continue;
enemies.push(new Enemy(x, y, type, mods)); enemies.push(new Enemy(x, y, type, mods));
} }
+9 -7
View File
@@ -1,6 +1,6 @@
import { FIXED_DT } from '../config'; import { FIXED_DT, GAME_LOOP } from '../config';
import type { Game } from '../core/Game'; import type { Game } from '../core/Game';
import type { KeyboardController } from '../input/KeyboardController'; import type { InputSource } from '../input/InputState';
/** /**
* Игровой цикл с ФИКСИРОВАННЫМ шагом. * Игровой цикл с ФИКСИРОВАННЫМ шагом.
@@ -15,11 +15,10 @@ export class GameLoop {
private last = 0; private last = 0;
private rafId = 0; private rafId = 0;
private running = false; private running = false;
private readonly maxSteps = 5; // защита от «спирали смерти» при лагах
constructor( constructor(
private readonly game: Game, private readonly game: Game,
private readonly controller: KeyboardController, private readonly controller: InputSource,
private readonly onRender: (alpha: number) => void, private readonly onRender: (alpha: number) => void,
) {} ) {}
@@ -31,16 +30,19 @@ export class GameLoop {
} }
stop(): void { stop(): void {
if (!this.running) return;
this.running = false; this.running = false;
cancelAnimationFrame(this.rafId); cancelAnimationFrame(this.rafId);
this.rafId = 0;
} }
private frame = (now: number): void => { private frame = (now: number): void => {
if (!this.running) return;
this.rafId = requestAnimationFrame(this.frame); this.rafId = requestAnimationFrame(this.frame);
let frameTime = (now - this.last) / 1000; let frameTime = (now - this.last) / 1000;
this.last = now; this.last = now;
if (frameTime > 0.25) frameTime = 0.25; // не «отыгрывать» долгие паузы (фон/таб) if (frameTime > GAME_LOOP.maxFrameTimeSec) frameTime = GAME_LOOP.maxFrameTimeSec;
// Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр. // Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр.
const input = this.controller.poll(); const input = this.controller.poll();
@@ -48,12 +50,12 @@ export class GameLoop {
this.accumulator += frameTime; this.accumulator += frameTime;
let steps = 0; let steps = 0;
while (this.accumulator >= FIXED_DT && steps < this.maxSteps) { while (this.accumulator >= FIXED_DT && steps < GAME_LOOP.maxStepsPerFrame) {
this.game.step(input); this.game.step(input);
this.accumulator -= FIXED_DT; this.accumulator -= FIXED_DT;
steps++; steps++;
} }
if (steps === this.maxSteps) this.accumulator = 0; // отстали — ресинхронизируемся if (steps === GAME_LOOP.maxStepsPerFrame) this.accumulator = 0; // отстали — ресинхронизируемся
const alpha = this.accumulator / FIXED_DT; const alpha = this.accumulator / FIXED_DT;
this.onRender(alpha); this.onRender(alpha);
+5
View File
@@ -19,6 +19,11 @@ export interface InputState {
restart: boolean; // рестарт на экране конца игры (однократно) restart: boolean; // рестарт на экране конца игры (однократно)
} }
/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */
export interface InputSource {
poll(): InputState;
}
/** Нейтральный снимок — ничего не нажато. */ /** Нейтральный снимок — ничего не нажато. */
export function emptyInput(): InputState { export function emptyInput(): InputState {
return { return {
+2 -2
View File
@@ -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;
+17
View File
@@ -41,4 +41,21 @@ describe('Rng', () => {
const shuffled = r.shuffle([...arr]); const shuffled = r.shuffle([...arr]);
expect([...shuffled].sort()).toEqual(arr); expect([...shuffled].sort()).toEqual(arr);
}); });
it('авто-seed не вызывает Math.random()', () => {
const math = Math as Math & { random: () => number };
const original = math.random;
math.random = () => { throw new Error('Math.random вызван'); };
try {
const r = new Rng();
expect(r.next()).toBeGreaterThanOrEqual(0);
expect(r.next()).toBeLessThan(1);
} finally {
math.random = original;
}
});
it('pick явно ругается на пустой массив', () => {
expect(() => new Rng(1).pick([])).toThrow('пустой массив');
});
}); });
+31
View File
@@ -4,6 +4,17 @@ import { Room } from '../src/core/world/Room';
import { Rng } from '../src/core/rng'; import { Rng } from '../src/core/rng';
import { OX, OY, TILE, DOOR, SPAWN } from '../src/config'; import { OX, OY, TILE, DOOR, SPAWN } from '../src/config';
import { dist } from '../src/core/util'; import { dist } from '../src/core/util';
import { DEFAULT_RULES, type LevelRules } from '../src/core/rules';
const denseRules: LevelRules = {
...DEFAULT_RULES,
id: 'dense-test',
name: 'Тестовая тесная комната',
description: 'Много врагов, чтобы проверить отказ от плохих позиций спавна.',
map: { ...DEFAULT_RULES.map },
player: { ...DEFAULT_RULES.player },
enemies: { ...DEFAULT_RULES.enemies, densityMul: 40 },
};
describe('spawner', () => { describe('spawner', () => {
it('в обычной комнате врагов в ожидаемом диапазоне', () => { it('в обычной комнате врагов в ожидаемом диапазоне', () => {
@@ -40,4 +51,24 @@ describe('spawner', () => {
} }
} }
}); });
it('РЕГРЕССИЯ: при переполнении не ставит врага в последнюю плохую точку', () => {
const room = new Room(1, 0, 'normal');
const playerX = OX + 7 * TILE;
const playerY = OY + 5 * TILE;
const door = DOOR.left;
const doorX = OX + door.cx * TILE + TILE / 2;
const doorY = OY + door.cy * TILE + TILE / 2;
const enemies = spawnEnemies(room, 'left', playerX, playerY, new Rng(12), denseRules);
expect(enemies.length).toBeGreaterThan(0);
for (let i = 0; i < enemies.length; i++) {
const e = enemies[i];
expect(dist(e.x, e.y, doorX, doorY)).toBeGreaterThanOrEqual(SPAWN.minDistFromDoor);
expect(dist(e.x, e.y, playerX, playerY)).toBeGreaterThanOrEqual(SPAWN.minDistFromPlayer);
for (let j = i + 1; j < enemies.length; j++) {
expect(dist(e.x, e.y, enemies[j].x, enemies[j].y)).toBeGreaterThanOrEqual(SPAWN.minDistBetween);
}
}
});
}); });