Author SHA1 Message Date
mayatnikov a72172df72 refactor: комплексный рефакторинг + Isaac-like геймплей
Баги и гигиена (Wave 1):
- Player.addWeapon использует MODE_MELEE вместо литерала 1
- Фильтрация мёртвых врагов из room.enemies (раньше массив рос в долгих боях)
- Кап миньёнов босса (BOSS.maxMinions=4) — иначе комната могла не зачиститься
- aliveCount корректно считает живых в knockback-фазе
- equipSlot валидирует диапазон слота
- Магические числа range=70/spread=0.15 вынесены в WeaponDef (beamRange/spread)
- Регрессия cleared-flag: после фильтрации массива длина 0, но cleared должен стать true

Распил Game.ts 683→465 строк (Wave 2):
- systems/movement.ts: moveEntity(e, dx, dy, room) — убрал 5 копий коллизионного паттерна
- systems/projectiles.ts: applyWeaponProjectileStats, explodeBomb, projectileHitWall
- systems/ai.ts: runAI через диспетчер-таблицу (вместо if/else-if каскада)
- Переходы/этажи оставлены в Game.ts (тесно завязаны на cc/cr/player)

Тесты 33→72 (Wave 3):
- movement.test.ts, projectiles.test.ts, combat.test.ts, regressions.test.ts, items.test.ts
- Покрытие: ближний/дальний бой, огнемёт, бомба, лазер, splitter, сундук→пикап,
  переход комнат, фазы босса + кап миньёнов, фильтрация мёртвых, секретка

Isaac-like геймплей (Wave 4):
- Статы игрока: damageMul/fireRateMul/rangeMul/shotSpeedMul (мультипликативно
  поверх WeaponDef), effectiveDamage/Cooldown, отображение в HUD
- 8-направленный прицел: aimDir: Dir → aimVec: {x,y}, стрелки дают диагонали
- Пассивные предметы (items.ts, 6 штук): сундук дропает 50/50 оружие/предмет
- Новый тип врага splitter: при смерти распадается на двух fast
- Новый тип комнаты secret: +1 max HP один раз при первом входе
2026-06-19 16:50:15 +03:00
Yuriy Mayatnikov 5d676b7d95 Merge pull request #4 from VovaManul/feature/new-game-plus
Merged feature/new-game-plus into main via gh.
2026-06-19 15:53:00 +03:00
mayatnikov 3cdd3ac738 Add Binding 2.0 asset pack 2026-06-19 15:50:02 +03:00
mayatnikov db545ce80b Add optimized README artwork 2026-06-19 13:27:47 +03:00
mayatnikov e55666f51c Fix new game plus progression issues 2026-06-19 13:26:19 +03:00
Volodia 01c1f71159 feat: инвентарь 2 слота, 4 новых оружия, 3 врага, босс-фазы, иконки пикапов 2026-06-19 13:09:28 +03:00
Manul d2d1d2381d Merge pull request #3 from halofourteen/codex/refactor-obvious-issues
[codex] Refactor core seams and spawning
2026-06-18 18:53:12 +03:00
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
90 changed files with 3086 additions and 175 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.
+4
View File
@@ -5,6 +5,10 @@ Top-down рогалик: процедурный данжен из комнат,
наклонная камера, пол лежит плашмя, персонажи — вертикальные спрайты-биллборды
(как в Isaac). Сборка — **Bun**.
<p align="center">
<img src="./docs/assets/binding_fignyashka.png" alt="Биндим Фигняшку" width="720" />
</p>
Есть **стартовое меню** с выбором уровня: данжен генерируется процедурно каждый
забег, но параметризуется набором правил (размер, плотность/сила врагов, HP, seed).
Двери видны всегда (закрыты в бою, открыты после зачистки). Графика — PNG-ассеты в
+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` из свежего клона).
+8 -1
View File
@@ -21,7 +21,7 @@ if (!result.success) {
await Bun.write('./dist/index.html', await Bun.file('./index.html').text());
// Копируем картинки в dist/assets (если папка есть).
// Копируем картинки в dist/<asset-pack> (если папка есть).
try {
await mkdir('./dist/assets', { recursive: true });
await cp('./src/assets', './dist/assets', { recursive: true });
@@ -29,4 +29,11 @@ try {
// 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)');
+1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "dungeon-crawl",
+2 -2
View File
@@ -22,8 +22,8 @@ const server = Bun.serve({
if (url.pathname === '/' || url.pathname === '/index.html') {
return new Response(Bun.file('./index.html'));
}
// Картинки отдаём прямо из src/assets/ — положил PNG → сразу подхватился (без пересборки).
if (url.pathname.startsWith('/assets/')) {
// Картинки отдаём прямо из 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 });
+10 -3
View File
@@ -37,7 +37,8 @@
Старый код двигал всё прямо в `requestAnimationFrame`, поэтому скорость игры
зависела от частоты монитора (на 144 Гц — в 2.4× быстрее). Теперь:
1. Раз в кадр опрашиваем ввод (`controller.poll()`).
1. Раз в кадр опрашиваем источник ввода (`controller.poll()`). Цикл знает только
маленький интерфейс `poll(): InputState`, а не конкретную клавиатуру.
2. Однократные действия (смена оружия, рестарт) — `game.consumeActions(input)`.
3. Копим реальное время в аккумуляторе и вызываем `game.step(input)` фиксированными
порциями по `1/60` секунды. Сколько бы ни был лаг — логика всегда идёт 60 шагов/с.
@@ -66,10 +67,11 @@
## Ввод (input/)
`InputState` — снимок намерений: оси движения, направление прицела, флаги
`InputState` — снимок намерений: оси движения, вектор прицела, флаги
удержания и однократные «edge»-действия. Делится на:
- **удерживаемые** (`moveX/Y`, `aimDir`, `attackHeld`) — читаются каждый шаг;
- **удерживаемые** (`moveX/Y`, `aimVec`, `attackHeld`) — читаются каждый шаг;
`aimVec` — вектор из стрелок, поддерживает 8 направлений (вкл. диагонали);
- **однократные** (`toggleWeapon`, `restart`) — срабатывают один раз на нажатие;
поэтому они обрабатываются в `consumeActions()` раз в кадр, а не в `step()`.
@@ -189,6 +191,11 @@ LevelRules ──► Game(rules) ──► RoomMap(rng, rules) // размер
└──► spawnEnemies(..., rules) // число/тип/сила врагов (множители)
```
В бесконечном спуске выбранный `rules` остаётся базовым пресетом забега, а
`Game` хранит отдельный активный снимок правил текущего этажа. Новая карта и
спавн врагов должны брать именно активные правила этажа, иначе данжен растёт, но
враги остаются с балансом первого этажа.
Граница: **геометрия движка** (размер тайла/комнаты, геометрия дверей) живёт в
`config.ts` и не меняется от уровня к уровню; **правила забега** — в `rules.ts`.
`config` задаёт базовые значения, `rules` — поверх (например, множители HP врагов).
+363
View File
@@ -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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

+10
View File
@@ -36,6 +36,16 @@
#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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+40
View File
@@ -28,6 +28,11 @@ export const OY = 80; // отступ комнаты сверху (м
export const FIXED_FPS = 60;
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 за шаг
maxHp: 6,
invFrames: 60, // неуязвимость после удара, в шагах
entryInvFrames: 20, // короткая неуязвимость при входе в комнату, в шагах
rangedCooldown: 10, // перезарядка выстрела, в шагах
meleeCooldown: 22, // перезарядка удара ближнего боя, в шагах
transitionLock: 15, // блок повторного перехода между комнатами, в шагах
@@ -118,6 +124,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 = {
@@ -137,6 +147,36 @@ export const SPAWN = {
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)
};
+302 -65
View File
@@ -1,20 +1,28 @@
import {
DIR, DOOR, OX, OY, TILE, COLS, ROWS, T_WALL,
MODE_RANGED, MODE_MELEE, PLAYER, ENEMY, MELEE,
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 { spawnEnemies } from './systems/spawner';
import { DEFAULT_RULES, type LevelRules } from './rules';
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 } from '../input/InputState';
import { pressingDir, cardinalFromVec } from '../input/InputState';
/**
* Game — «мозг» игры. Полностью независим от рендера и DOM: ничего не
@@ -28,6 +36,7 @@ import { pressingDir } from '../input/InputState';
*/
export class Game {
readonly rules: LevelRules;
private floorRules: LevelRules;
rng: Rng; // пересоздаётся в reset() — для воспроизводимости фикс-сида
roomMap: RoomMap;
player: Player;
@@ -36,6 +45,9 @@ export class Game {
meleeSwing: MeleeSwing | null = null;
gameOver = false;
won = false;
floor = 1;
inventoryOpen = false;
elapsedSteps = 0;
/**
* @param rules правила уровня (см. core/rules.ts). По умолчанию — «Стандарт».
@@ -43,9 +55,11 @@ export class Game {
*/
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.roomMap = new RoomMap(this.rng, rules);
this.player.mode = this.player.currentWeapon.type === 'ranged' ? MODE_RANGED : MODE_MELEE;
this.roomMap = new RoomMap(this.rng, this.floorRules);
this.enterRoom('up');
}
@@ -56,19 +70,40 @@ export class Game {
// ── Публичный контракт цикла ──────────────────────────────
/** Однократные действия (смена оружия, рестарт). Вызывать раз в кадр. */
/** Однократные действия (смена оружия, рестарт, инвентарь). Вызывать раз в кадр. */
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) {
this.player.mode = this.player.mode === MODE_RANGED ? MODE_MELEE : MODE_RANGED;
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) return;
if (this.gameOver || this.won || this.inventoryOpen) return;
this.elapsedSteps++;
const room = this.curRoom;
const p = this.player;
@@ -87,11 +122,16 @@ export class Game {
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;
// Комната зачищена: открываем двери.
if (room.enemies.length > 0 && aliveCount === 0 && !room.cleared) {
// Комната зачищена: все враги мертвы (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();
}
@@ -104,10 +144,15 @@ export class Game {
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.rules);
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;
@@ -119,6 +164,7 @@ export class Game {
/** Расставляет игрока внутри текущей комнаты у двери fromDir и (при нужде) спавнит врагов. */
enterRoom(fromDir: Dir): void {
const room = this.curRoom;
const wasVisited = room.visited; // ловим «первый вход» до установки флага
room.visited = true;
const d = DOOR[fromDir];
@@ -128,16 +174,24 @@ export class Game {
const py = OY + d.cy * TILE + TILE / 2 - ddr * TILE;
this.player.place(px, py);
this.player.facing = fromDir;
this.player.invTimer = 20; // короткая неуязвимость на входе
this.player.invTimer = PLAYER.entryInvFrames;
this.player.transCD = PLAYER.transitionLock;
this.meleeSwing = null;
room.tears = [];
if (!room.cleared && room.type !== 'spawn') {
room.enemies = spawnEnemies(room, fromDir, this.player.x, this.player.y, this.rng, this.rules);
// Если врагов нет (напр. сокровищница) — зачищать нечего, открываем сразу,
// иначе двери никогда не появятся и игрок застрянет.
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 {
@@ -163,29 +217,69 @@ export class Game {
if (input.moveX < 0) p.moveDir = 'left';
else if (input.moveX > 0) p.moveDir = 'right';
const dx = mx * p.speed;
const dy = my * p.speed;
// Раздельное разрешение коллизий по осям: позволяет «скользить» вдоль стен.
p.x += dx;
if (collidesWall(p.box, room)) p.x -= dx;
p.y += dy;
if (collidesWall(p.box, room)) p.y -= dy;
moveEntity(p, mx * p.speed, my * p.speed, room);
}
private handleAttack(input: InputState, room: Room, p: Player): void {
let dir: Dir | null = null;
if (input.aimDir) dir = input.aimDir; // прицельная стрельба стрелками
else if (input.attackHeld) dir = p.moveDir; // пробел — по ходу движения
// Приоритет: явный прицел (вектор стрелок) → иначе направление движения.
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 (!dir || p.atkCD > 0) return;
if (!aim || p.atkCD > 0) return;
p.facing = dir;
p.atkCD = p.mode === MODE_RANGED ? PLAYER.rangedCooldown : PLAYER.meleeCooldown;
const [nx, ny] = DIR[dir];
if (p.mode === MODE_RANGED) {
room.tears.push(new Projectile(p.x, p.y, nx, ny));
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, dir);
this.meleeSwing = new MeleeSwing(p.x, p.y, cardinalFromVec({ x: nx, y: ny }), {
damage,
knockback: w.knockback,
life: w.swingLife,
sizeMul: w.swingSizeMul,
});
}
}
@@ -198,56 +292,162 @@ export class Game {
if (!e.alive || e.hitTimer > 0) continue;
if (overlap(e.box, this.meleeSwing.box)) {
e.hp -= this.meleeSwing.damage;
e.hitTimer = MELEE.life; // защита от повторного удара тем же взмахом
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--;
const col = Math.floor((t.x - OX) / TILE);
const row = Math.floor((t.y - OY) / TILE);
if (col < 0 || col >= COLS || row < 0 || row >= ROWS || t.life <= 0) {
if (t.life <= 0 || projectileHitWall(t, room)) {
explodeBomb(room, t);
t.life = 0;
continue;
}
if (room.tiles[row][col] === T_WALL) {
t.life = 0;
continue;
}
for (const e of room.enemies) {
if (!e.alive) continue;
if (dist(t.x, t.y, e.x, e.y) < e.w / 2 + t.r) {
e.hp -= t.damage;
e.hitTimer = ENEMY.hitFlash;
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;
break;
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) {
if (!e.alive) continue;
aliveCount++;
// Мёртвый враг: ловим splitter для распада, иначе пропускаем.
if (!e.alive) {
if (e.type === 'splitter') deadSplitters.push(e);
continue;
}
if (e.hitTimer > 0) e.hitTimer--;
// Фаза отбрасывания: летит по инерции, ИИ не работает. Коллизии
// проверяем пораздельно по осям — иначе кнокбэк (до ~4.5 тайла)
// пробивал стену в 1 тайл, и враг застревал снаружи навсегда (софт-лок).
// Горение: урон каждые 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;
@@ -255,23 +455,21 @@ export class Game {
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;
// Преследование игрока.
const dx = p.x - e.x;
const dy = p.y - e.y;
const d = Math.hypot(dx, dy);
if (d > 0 && d < ENEMY.aggroRange) {
const mx = (dx / d) * e.speed;
const my = (dy / d) * e.speed;
e.x += mx;
if (collidesWall(e.box, room)) e.x -= mx;
e.y += my;
if (collidesWall(e.box, room)) e.y -= my;
}
aliveCount++;
runAI(e, ctx);
// Если за этот шаг враг умер от ИИ-фазы или снаряда (маловероятно,
// но возможно при касании уже летящего), отслеживаем.
if (!e.alive && e.type === 'splitter') deadSplitters.push(e);
if (!e.alive) continue;
// Контактный урон по игроку.
if (e.atkTimer > 0) e.atkTimer--;
@@ -282,11 +480,22 @@ export class Game {
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;
}
@@ -315,10 +524,38 @@ export class Game {
}
}
/** Спуск на следующий этаж: новая карта, усиленные враги, 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) {
this.won = true;
if (this.rules.endless) {
this.descend();
} else {
this.won = true;
}
return;
}
}
+24
View File
@@ -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;
}
}
+12 -4
View File
@@ -18,10 +18,18 @@ export class Enemy {
readonly maxHp: number;
readonly speed: number;
readonly damage: number;
knx = 0; // отбрасывание по X
kny = 0; // отбрасывание по Y
hitTimer = 0; // мигание при попадании (шаги)
atkTimer = 0; // перезарядка контактного удара (шаги)
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,
+15 -9
View File
@@ -1,23 +1,29 @@
import { MELEE, DIR } from '../../config';
import type { Dir, Box } from '../types';
/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на MELEE.life шагов. */
/** Взмах ближнего боя: прямоугольный хитбокс перед игроком на life шагов. */
export class MeleeSwing {
readonly dir: Dir;
life = MELEE.life;
readonly damage = MELEE.damage;
readonly kb = MELEE.knockback;
readonly maxLife: number;
readonly damage: number;
readonly kb: number;
readonly box: Box;
constructor(x: number, y: number, dir: Dir) {
constructor(x: number, y: number, dir: Dir, overrides?: { damage?: number; knockback?: number; life?: number; sizeMul?: number }) {
this.dir = dir;
const { reach: d, size: s } = MELEE;
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 - s : -s / 2),
y: y + (dy > 0 ? d : dy < 0 ? -d - s : -s / 2),
w: s,
h: s,
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,
};
}
+48
View File
@@ -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;
+65 -1
View File
@@ -1,6 +1,31 @@
import { PLAYER, MODE_RANGED } from '../../config';
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 хранят позицию на прошлом шаге для плавной интерполяции
@@ -23,6 +48,14 @@ export class Player {
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;
@@ -35,6 +68,37 @@ export class Player {
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;
+13 -4
View File
@@ -1,6 +1,7 @@
import { PROJECTILE } from '../../config';
import type { ProjectileType } from '../types';
/** Снаряд игрока («слеза»). Летит по прямой, пока не врежется или не истечёт life. */
/** Снаряд. Летит по прямой, пока не врежется или не истечёт life. */
export class Projectile {
x: number;
y: number;
@@ -8,16 +9,24 @@ export class Projectile {
prevY: number;
dx: number;
dy: number;
readonly type: ProjectileType;
readonly r = PROJECTILE.radius;
readonly speed = PROJECTILE.speed;
readonly damage = PROJECTILE.damage;
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) {
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 {
+78
View File
@@ -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);
}
+23 -3
View File
@@ -12,9 +12,9 @@ export class Rng {
private state: number;
/** Без seed — случайный старт; с seed — детерминированная цепочка. */
constructor(seed?: number) {
// 0 — валидный seed, поэтому проверяем именно на undefined.
this.state = (seed === undefined ? (Math.random() * 2 ** 32) >>> 0 : seed) >>> 0;
constructor(seed: number = autoSeed()) {
// 0 — валидный seed; авто-seed включается только когда аргумент не передан.
this.state = seed >>> 0;
}
/** Следующее число в [0, 1). Алгоритм mulberry32 — быстрый и достаточный. */
@@ -43,6 +43,7 @@ export class Rng {
/** Случайный элемент массива. */
pick<T>(arr: readonly T[]): T {
if (arr.length === 0) throw new Error('Rng.pick: пустой массив');
return arr[this.int(0, arr.length - 1)];
}
@@ -55,3 +56,22 @@ export class Rng {
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;
}
+37 -1
View File
@@ -10,7 +10,7 @@
* Геометрия (размер тайла/комнаты, геометрия дверей) остаётся в config.ts: это
* не «правила уровня», а константы движка.
*/
import { PLAYER, ENEMY, MIN_ROOMS, EXTRA_ROOMS, MAP_RADIUS } from '../config';
import { PLAYER, ENEMY, MIN_ROOMS, EXTRA_ROOMS, MAP_RADIUS, FLOOR_SCALING } from '../config';
export interface LevelRules {
/** Машинный id (для сохранений/выбора). */
@@ -21,6 +21,8 @@ export interface LevelRules {
description: string;
/** Фиксированный seed генерации. undefined → случайный каждый забег. */
seed?: number;
/** Бесконечный спуск: после босса — новый этаж с усилением, а не победа. */
endless?: boolean;
/** Параметры генерации карты. */
map: {
@@ -91,4 +93,38 @@ export const PRESETS: LevelRules[] = [
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,
};
}
+171
View File
@@ -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 }));
}
}
}
+26
View File
@@ -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;
}
+65
View File
@@ -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;
+48 -13
View File
@@ -1,17 +1,48 @@
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,
@@ -25,7 +56,7 @@ export function spawnEnemies(
const count =
room.type === 'boss' ? 1 :
room.type === 'treasure' ? 0 :
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];
@@ -33,8 +64,7 @@ export function spawnEnemies(
const doorY = OY + door.cy * TILE + TILE / 2;
for (let i = 0; i < count; i++) {
const type: EnemyType =
room.type === 'boss' ? 'boss' : rng.chance(er.fastChance) ? 'fast' : 'normal';
const type: EnemyType = pickEnemyType(room, rng, er.fastChance);
const mods = {
hpMul: er.hpMul * (type === 'boss' ? er.bossHpMul : 1),
speedMul: er.speedMul,
@@ -43,22 +73,27 @@ export function spawnEnemies(
let x = 0;
let y = 0;
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;
y = OY + 2 * TILE + rng.float(0, ROWS - 4) * TILE;
ok = true;
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; }
}
}
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);
}
+3 -2
View File
@@ -1,9 +1,10 @@
/** Общие типы данных, на которые опирается вся игра. */
export type RoomType = 'spawn' | 'normal' | 'treasure' | 'boss';
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';
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 {
+41
View File
@@ -0,0 +1,41 @@
export type WeaponId = 'tears' | 'melee' | 'shotgun' | 'axe' | 'staff' | 'whip' | 'bomb' | 'boomerang' | 'laser';
export type ProjectileType = 'tear' | 'fireball' | 'bomb' | 'boomerang' | 'laser' | 'beam';
export interface WeaponDef {
id: WeaponId;
name: string;
type: 'ranged' | 'melee';
damage: number;
cooldown: number;
projectileType?: ProjectileType;
spreadCount?: number;
/** Угол бокового отклонения каждого снаряда при spreadCount > 1 (доля от перпендикуляра). */
spread?: number;
swingSizeMul?: number;
swingLife?: number;
knockback?: number;
fireDmg?: number;
fireInterval?: number;
fireDuration?: number;
explosionRadius?: number; // для бомбы
beamLife?: number; // длительность лазерного луча
beamRadius?: number; // радиус поражения луча
beamTickDmg?: number; // урон за тик луча
beamRange?: number; // дальность постановки центра луча от игрока
}
export const WEAPONS: Record<WeaponId, WeaponDef> = {
tears: { id: 'tears', name: 'Слёзы', type: 'ranged', damage: 1, cooldown: 10, projectileType: 'tear' },
melee: { id: 'melee', name: 'Кулак', type: 'melee', damage: 2, cooldown: 22 },
shotgun: { id: 'shotgun', name: 'Дробовик', type: 'ranged', damage: 1, cooldown: 18, projectileType: 'tear', spreadCount: 3, spread: 0.15 },
axe: { id: 'axe', name: 'Топор', type: 'melee', damage: 4, cooldown: 35, swingSizeMul: 1.5, swingLife: 15, knockback: 15 },
staff: { id: 'staff', name: 'Посох', type: 'ranged', damage: 1, cooldown: 20, projectileType: 'fireball', fireDmg: 1, fireInterval: 10, fireDuration: 50 },
whip: { id: 'whip', name: 'Хлыст', type: 'melee', damage: 3, cooldown: 18, swingSizeMul: 2.5, swingLife: 12, knockback: 16 },
bomb: { id: 'bomb', name: 'Бомба', type: 'ranged', damage: 0, cooldown: 35, projectileType: 'bomb', explosionRadius: 80 },
boomerang: { id: 'boomerang', name: 'Бумеранг', type: 'ranged', damage: 2, cooldown: 25, projectileType: 'boomerang' },
laser: {
id: 'laser', name: 'Лазер', type: 'ranged', damage: 1, cooldown: 20,
projectileType: 'beam', beamLife: 12, beamRadius: 44, beamTickDmg: 2, beamRange: 70,
},
};
+4
View File
@@ -2,6 +2,8 @@ import type { RoomType, Doors } from '../types';
import { buildTiles } from './tiles';
import type { Enemy } from '../entities/Enemy';
import type { Projectile } from '../entities/Projectile';
import type { Chest } from '../entities/Chest';
import type { Pickup } from '../entities/Pickup';
/**
* Комната дандженa. Хранит свой тип, набор дверей, состояние «зачищена/
@@ -17,6 +19,8 @@ export class Room {
cleared = false;
enemies: Enemy[] = [];
tears: Projectile[] = [];
chest: Chest | null = null;
pickup: Pickup | null = null;
tiles: number[][];
constructor(c: number, r: number, type: RoomType) {
+2
View File
@@ -76,6 +76,8 @@ export class RoomMap {
type = 'boss';
} else if (rng.chance(SPAWN.treasureChance) && count >= 2) {
type = 'treasure';
} else if (rng.chance(SPAWN.secretChance) && count >= 3) {
type = 'secret';
}
this.add(nc, nr, type);
+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 { KeyboardController } from '../input/KeyboardController';
import type { InputSource } from '../input/InputState';
/**
* Игровой цикл с ФИКСИРОВАННЫМ шагом.
@@ -15,11 +15,10 @@ export class GameLoop {
private last = 0;
private rafId = 0;
private running = false;
private readonly maxSteps = 5; // защита от «спирали смерти» при лагах
constructor(
private readonly game: Game,
private readonly controller: KeyboardController,
private readonly controller: InputSource,
private readonly onRender: (alpha: number) => void,
) {}
@@ -31,16 +30,19 @@ export class GameLoop {
}
stop(): void {
if (!this.running) return;
this.running = false;
cancelAnimationFrame(this.rafId);
this.rafId = 0;
}
private frame = (now: number): void => {
if (!this.running) return;
this.rafId = requestAnimationFrame(this.frame);
let frameTime = (now - this.last) / 1000;
this.last = now;
if (frameTime > 0.25) frameTime = 0.25; // не «отыгрывать» долгие паузы (фон/таб)
if (frameTime > GAME_LOOP.maxFrameTimeSec) frameTime = GAME_LOOP.maxFrameTimeSec;
// Ввод опрашиваем раз в кадр; однократные действия — тоже раз в кадр.
const input = this.controller.poll();
@@ -48,12 +50,12 @@ export class GameLoop {
this.accumulator += frameTime;
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.accumulator -= FIXED_DT;
steps++;
}
if (steps === this.maxSteps) this.accumulator = 0; // отстали — ресинхронизируемся
if (steps === GAME_LOOP.maxStepsPerFrame) this.accumulator = 0; // отстали — ресинхронизируемся
const alpha = this.accumulator / FIXED_DT;
this.onRender(alpha);
+33 -8
View File
@@ -7,16 +7,27 @@ import type { Dir } from '../core/types';
* контроллер, отдающий такой же InputState.
*
* Поля делятся на два вида:
* • удерживаемые (move*, aimDir, attackHeld) — читаются каждый шаг симуляции;
* • удерживаемые (move*, aimVec, attackHeld) — читаются каждый шаг симуляции;
* • однократные «edge» (toggleWeapon, restart) — срабатывают один раз на нажатие.
*/
export interface InputState {
moveX: number; // -1 влево, +1 вправо, 0 нет
moveY: number; // -1 вверх, +1 вниз, 0 нет
aimDir: Dir | null; // прицеливание стрелками (приоритетнее attackHeld)
/**
* Вектор прицеливания (например, из стрелок). Может быть диагональным:
* {1,-1} = вверх-вправо. null = игрок не целится явным образом. Не обязан
* быть нормализованным — приведением займётся логика.
*/
aimVec: { x: number; y: number } | null;
attackHeld: boolean; // атака «по ходу движения» (пробел)
toggleWeapon: boolean; // сменить оружие (однократно)
restart: boolean; // рестарт на экране конца игры (однократно)
openInventory: boolean; // открыть инвентарь (однократно, E)
}
/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */
export interface InputSource {
poll(): InputState;
}
/** Нейтральный снимок — ничего не нажато. */
@@ -24,19 +35,33 @@ export function emptyInput(): InputState {
return {
moveX: 0,
moveY: 0,
aimDir: null,
aimVec: null,
attackHeld: false,
toggleWeapon: false,
restart: false,
openInventory: false,
};
}
/** Жмёт ли игрок в сторону dir (движением ИЛИ прицеливанием) — для переходов. */
/**
* True, если вектор прицеливания или движения указывает в сторону dir.
* Нужно для переходов между комнатами (можно жать стрелку ИЛИ движение).
*/
export function pressingDir(input: InputState, dir: Dir): boolean {
switch (dir) {
case 'up': return input.moveY < 0 || input.aimDir === 'up';
case 'down': return input.moveY > 0 || input.aimDir === 'down';
case 'left': return input.moveX < 0 || input.aimDir === 'left';
case 'right': return input.moveX > 0 || input.aimDir === 'right';
case 'up': return input.moveY < 0 || (!!input.aimVec && input.aimVec.y < 0);
case 'down': return input.moveY > 0 || (!!input.aimVec && input.aimVec.y > 0);
case 'left': return input.moveX < 0 || (!!input.aimVec && input.aimVec.x < 0);
case 'right': return input.moveX > 0 || (!!input.aimVec && input.aimVec.x > 0);
}
}
/**
* Округлить вектор прицеливания до ближайшего из 4 кардинальных направлений.
* Используется для `facing` (рендер спрайта игрока) и для случаев, когда
* логике нужен именно Dir (например, поставить дверь в комнату).
*/
export function cardinalFromVec(v: { x: number; y: number }): Dir {
if (Math.abs(v.x) > Math.abs(v.y)) return v.x > 0 ? 'right' : 'left';
return v.y > 0 ? 'down' : 'up';
}
+18 -10
View File
@@ -1,5 +1,4 @@
import type { InputState } from './InputState';
import type { Dir } from '../core/types';
import type { InputSource, InputState } from './InputState';
/**
* Раскладка: WASD — движение, стрелки — прицельная стрельба, пробел —
@@ -14,10 +13,11 @@ import type { Dir } from '../core/types';
* (смена оружия/рестарт). Раз в кадр вызывается poll(), который собирает
* InputState и сбрасывает однократные флаги.
*/
export class KeyboardController {
export class KeyboardController implements InputSource {
private held = new Set<string>();
private toggleWeaponEdge = false;
private restartEdge = false;
private inventoryEdge = false;
private attached = false;
private onKeyDown = (e: KeyboardEvent): void => {
@@ -26,6 +26,7 @@ export class KeyboardController {
if (!this.held.has(c)) {
if (c === 'Tab' || c === 'KeyQ') this.toggleWeaponEdge = true;
if (c === 'KeyR') this.restartEdge = true;
if (c === 'KeyE') this.inventoryEdge = true;
}
this.held.add(c);
if (PREVENT.has(c)) e.preventDefault();
@@ -49,6 +50,7 @@ export class KeyboardController {
this.held.clear();
this.toggleWeaponEdge = false;
this.restartEdge = false;
this.inventoryEdge = false;
}
/** Подписаться на события окна. Вызывается один раз при старте. */
@@ -71,28 +73,34 @@ export class KeyboardController {
if (down('KeyA')) moveX -= 1;
if (down('KeyD')) moveX += 1;
let aimDir: Dir | null = null;
if (down('ArrowUp')) aimDir = 'up';
else if (down('ArrowDown')) aimDir = 'down';
else if (down('ArrowLeft')) aimDir = 'left';
else if (down('ArrowRight')) aimDir = 'right';
// Прицел собираем из стрелок как ВЕКТОР — поддерживает 8 направлений
// (одновременные ArrowUp + ArrowRight дают диагональ {0,-1}+{1,0}).
let aimX = 0;
let aimY = 0;
if (down('ArrowUp')) aimY -= 1;
if (down('ArrowDown')) aimY += 1;
if (down('ArrowLeft')) aimX -= 1;
if (down('ArrowRight')) aimX += 1;
const aimVec = (aimX !== 0 || aimY !== 0) ? { x: aimX, y: aimY } : null;
const snapshot: InputState = {
moveX,
moveY,
aimDir,
aimVec,
attackHeld: down('Space'),
toggleWeapon: this.toggleWeaponEdge,
restart: this.restartEdge,
openInventory: this.inventoryEdge,
};
this.toggleWeaponEdge = false;
this.restartEdge = false;
this.inventoryEdge = false;
return snapshot;
}
}
/** Физические клавиши (e.code), у которых гасим поведение браузера (скролл/таб). */
const PREVENT = new Set([
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab',
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'Tab', 'KeyE',
]);
+33 -7
View File
@@ -13,6 +13,7 @@ import { ThreeRenderer } from './render/ThreeRenderer';
import { HudOverlay } from './render/HudOverlay';
import { GameLoop } from './engine/GameLoop';
import { StartMenu } from './ui/StartMenu';
import { ASSET_PACKS, type AssetPack } from './render/assetPacks';
function boot(): void {
const world = document.getElementById('game') as HTMLCanvasElement | null;
@@ -24,21 +25,27 @@ function boot(): void {
return;
}
// Рендер и ввод создаём один раз — они переиспользуются между забегами.
// Ввод живёт всё приложение; рендер создаётся на забег, потому что зависит от пака ассетов.
const controller = new KeyboardController();
const world3d = new ThreeRenderer(world);
const hud = new HudOverlay(hudCanvas);
controller.attach();
let loop: GameLoop | null = null;
let world3d: ThreeRenderer | null = null;
let hud: HudOverlay | null = null;
const startGame = (rules: LevelRules): void => {
const startGame = (rules: LevelRules, assetPack: AssetPack): void => {
loop?.stop();
world3d?.dispose();
hud?.dispose();
controller.reset(); // чистый ввод: не тащим зажатые клавиши/смену оружия из прошлого забега
const game = new Game(rules);
const currentWorld = new ThreeRenderer(world, assetPack.path);
const currentHud = new HudOverlay(hudCanvas, assetPack.path);
world3d = currentWorld;
hud = currentHud;
loop = new GameLoop(game, controller, (alpha) => {
world3d.render(game, alpha);
hud.render(game);
currentWorld.render(game, alpha);
currentHud.render(game);
});
menu.hide();
loop.start();
@@ -49,18 +56,37 @@ function boot(): void {
const toMenu = (): void => {
loop?.stop();
loop = null;
world3d?.dispose();
hud?.dispose();
world3d = null;
hud = null;
menu.show(); // фон меню перекрывает «замёрзший» последний кадр
};
const menu = new StartMenu(menuEl, PRESETS, startGame);
const menu = new StartMenu(menuEl, PRESETS, ASSET_PACKS, startGame);
menu.show();
// Esc во время игры — вернуться к выбору уровня (по физической клавише).
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape' && loop) {
const g = (window as Window & { game?: Game }).game;
if (g?.inventoryOpen) {
g.closeInventory();
e.preventDefault();
return;
}
e.preventDefault();
toMenu();
}
// Цифры 1-9 для экипировки оружия в инвентаре.
const digit = parseInt(e.code.replace('Digit', ''), 10);
if (digit >= 1 && digit <= 9 && loop) {
const g = (window as Window & { game?: Game }).game;
if (g?.inventoryOpen) {
g.equipSlot(digit - 1);
e.preventDefault();
}
}
});
}
+79 -16
View File
@@ -1,4 +1,4 @@
import { CW, CH, OY, RH, MODE_RANGED } from '../config';
import { CW, CH, OY, RH } from '../config';
import type { Game } from '../core/Game';
import type { Renderer } from './Renderer';
@@ -10,19 +10,21 @@ import type { Renderer } from './Renderer';
export class HudOverlay implements Renderer {
private readonly ctx: CanvasRenderingContext2D;
private readonly images = new Map<string, HTMLImageElement>();
private readonly assetBasePath: string;
/** Лениво грузит PNG из assets/<name>.png; возвращает картинку, только когда она готова. */
/** Лениво грузит PNG из выбранного пака; возвращает картинку, только когда она готова. */
private img(name: string): HTMLImageElement | null {
let im = this.images.get(name);
if (!im) {
im = new Image();
im.src = `assets/${name}.png`;
im.src = `${this.assetBasePath}/${name}.png`;
this.images.set(name, im);
}
return im.complete && im.naturalWidth > 0 ? im : null;
}
constructor(canvas: HTMLCanvasElement) {
constructor(canvas: HTMLCanvasElement, assetBasePath = 'assets') {
this.assetBasePath = assetBasePath;
// Буфер увеличиваем под плотность пикселей (чёткий текст на HiDPI),
// а рисуем по-прежнему в логических координатах CW×CH.
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -36,6 +38,11 @@ export class HudOverlay implements Renderer {
const ctx = this.ctx;
ctx.clearRect(0, 0, CW, CH);
if (game.inventoryOpen) {
this.drawInventory(game);
return;
}
this.drawHud(game);
this.drawMinimap(game);
@@ -55,22 +62,34 @@ export class HudOverlay implements Renderer {
// Здоровье: сердечки (2 HP = сердце), с фолбэком на полосу.
const healthBottom = this.drawHealth(p.hp, p.maxHp);
// Название текущего уровня (правил).
// Название пресета и номер этажа (для бесконечного спуска).
ctx.textAlign = 'left'; ctx.fillStyle = '#667'; ctx.font = '11px monospace';
ctx.fillText(`Уровень: ${game.rules.name}`, 20, healthBottom + 14);
const floorLabel = game.rules.endless ? ` | Этаж ${game.floor}` : '';
ctx.fillText(`${game.rules.name}${floorLabel}`, 20, healthBottom + 14);
// Индикатор режима боя.
// Активные множители стат игрока (от предметов). Показываем только
// отличные от нейтральных — чтобы не засорять UI в начале забега.
const s = p.stats;
const bits: string[] = [];
if (s.damageMul !== 1) bits.push(`DMG ×${s.damageMul.toFixed(2)}`);
if (s.fireRateMul !== 1) bits.push(`RATE ×${s.fireRateMul.toFixed(2)}`);
if (s.rangeMul !== 1) bits.push(`RNG ×${s.rangeMul.toFixed(2)}`);
if (s.shotSpeedMul !== 1) bits.push(`SPD ×${s.shotSpeedMul.toFixed(2)}`);
if (bits.length) {
ctx.fillStyle = '#9a7'; ctx.font = '10px monospace';
ctx.fillText(bits.join(' '), 20, healthBottom + 28);
}
// Индикатор оружия.
const my = CH - 46;
const ranged = p.mode === MODE_RANGED;
const mText = ranged ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ';
const mCol = ranged ? '#4488cc' : '#cc6644';
const w = p.currentWeapon;
const wCol = w.type === 'ranged' ? '#4488cc' : '#cc6644';
ctx.textAlign = 'center';
ctx.fillStyle = '#0d0d0d'; ctx.fillRect(CW / 2 - 95, my - 18, 190, 34);
ctx.strokeStyle = mCol; ctx.lineWidth = 2; ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
ctx.fillStyle = mCol; ctx.font = 'bold 17px monospace'; ctx.fillText(`[ ${mText} ]`, CW / 2, my + 8);
ctx.strokeStyle = wCol; ctx.lineWidth = 2; ctx.strokeRect(CW / 2 - 95, my - 18, 190, 34);
ctx.fillStyle = wCol; ctx.font = 'bold 17px monospace'; ctx.fillText(`[ ${w.name} ]`, CW / 2, my + 8);
ctx.fillStyle = '#555'; ctx.font = '11px monospace'; ctx.fillText('[Tab] сменить оружие', CW / 2, my - 26);
// Иконка оружия слева в рамке (если ассет есть).
const icon = this.img(ranged ? 'icon-ranged' : 'icon-melee');
const icon = this.img(w.type === 'ranged' ? 'icon-ranged' : 'icon-melee');
if (icon) ctx.drawImage(icon, CW / 2 - 90, my - 14, 26, 26);
// Счётчик врагов / подсказка зачистки.
@@ -86,7 +105,7 @@ export class HudOverlay implements Renderer {
// Подпись типа комнаты.
if (room.visited) {
const label = { spawn: 'СТАРТ', normal: '', treasure: 'СОКРОВИЩЕ', boss: 'БОСС' }[room.type];
const label = { spawn: 'СТАРТ', normal: '', treasure: 'СОКРОВИЩЕ', boss: 'БОСС', secret: 'СЕКРЕТ' }[room.type];
if (label) {
ctx.textAlign = 'right'; ctx.fillStyle = '#555'; ctx.font = '11px monospace';
ctx.fillText(label, CW - 20, OY + RH + 30);
@@ -139,7 +158,7 @@ export class HudOverlay implements Renderer {
let color = '#141414';
if (room.visited) {
color = { spawn: '#2a5a2a', boss: '#5a1a1a', treasure: '#5a5a1a', normal: '#555' }[room.type];
color = { spawn: '#2a5a2a', boss: '#5a1a1a', treasure: '#5a5a1a', secret: '#1a3a5a', normal: '#555' }[room.type];
}
ctx.fillStyle = color; ctx.fillRect(x, y, cell, cell);
@@ -167,4 +186,48 @@ export class HudOverlay implements Renderer {
ctx.fillStyle = '#666'; ctx.font = '14px monospace';
ctx.fillText('[Esc] в меню', CW / 2, CH / 2 + 68);
}
private drawInventory(game: import('../core/Game').Game): void {
const ctx = this.ctx;
ctx.fillStyle = 'rgba(0,0,0,0.85)'; ctx.fillRect(0, 0, CW, CH);
ctx.fillStyle = '#ddd'; ctx.font = 'bold 28px monospace'; ctx.textAlign = 'center';
ctx.fillText('ИНВЕНТАРЬ (2 слота)', CW / 2, 50);
const p = game.player;
const iw = 340, ih = 56, gap = 16;
const total = iw * 2 + gap;
const ox = CW / 2 - total / 2;
const oy = 100;
for (let i = 0; i < 2; i++) {
const w = p.weapons[i];
const x = ox + i * (iw + gap);
const y = oy;
const selected = i === p.equipped;
ctx.fillStyle = selected ? '#1a2a1a' : '#111';
ctx.fillRect(x, y, iw, ih);
ctx.strokeStyle = selected ? '#4c4' : '#333';
ctx.lineWidth = selected ? 2 : 1;
ctx.strokeRect(x, y, iw, ih);
ctx.fillStyle = '#888'; ctx.font = '14px monospace'; ctx.textAlign = 'left';
ctx.fillText(`[${i + 1}]`, x + 12, y + 34);
ctx.fillStyle = selected ? '#4c4' : '#ccc'; ctx.font = 'bold 16px monospace';
ctx.fillText(w.name, x + 48, y + 34);
ctx.fillStyle = '#666'; ctx.font = '12px monospace';
ctx.textAlign = 'right';
const t = w.type === 'ranged' ? 'ДАЛЬНИЙ' : 'БЛИЖНИЙ';
ctx.fillText(`${t} DMG:${w.damage} CD:${w.cooldown}`, x + iw - 12, y + 34);
const icon = this.img(w.type === 'ranged' ? 'icon-ranged' : 'icon-melee');
if (icon) ctx.drawImage(icon, x + iw - 44, y + 16, 24, 24);
}
ctx.fillStyle = '#555'; ctx.font = '13px monospace'; ctx.textAlign = 'center';
ctx.fillText('[E] закрыть | [1] [2] слот | [Tab] переключить', CW / 2, CH - 30);
}
}
+92 -10
View File
@@ -39,7 +39,7 @@ export class ThreeRenderer implements Renderer {
private readonly renderer: THREE.WebGLRenderer;
private readonly scene = new THREE.Scene();
private readonly camera: THREE.PerspectiveCamera;
private readonly assets = new Assets();
private readonly assets: Assets;
private readonly theme: Theme;
// Общие геометрии.
@@ -53,6 +53,8 @@ export class ThreeRenderer implements Renderer {
private readonly playerMat: Record<'ranged' | 'melee', THREE.MeshBasicMaterial>;
private readonly enemyMatKey: Record<Enemy['type'], SpriteKey> = {
normal: 'enemy-normal', fast: 'enemy-fast', boss: 'enemy-boss',
charger: 'enemy-charger', tank: 'enemy-tank', shooter: 'enemy-shooter',
splitter: 'enemy-fast', // пока используем визуал fast, пока нет отдельной текстуры
};
// Группа статичной геометрии комнаты (пол + стены + двери).
@@ -69,8 +71,14 @@ export class ThreeRenderer implements Renderer {
private readonly effects: Effect[] = [];
private lastAtkCD = 0;
constructor(canvas: HTMLCanvasElement, theme: Theme = DEFAULT_THEME) {
this.theme = theme;
private chestMesh: THREE.Mesh | null = null;
private pickupMesh: THREE.Mesh | null = null;
private currentPickupWeapon: string | null = null;
constructor(canvas: HTMLCanvasElement, assetBasePathOrTheme: string | Theme = 'assets', theme: Theme = DEFAULT_THEME) {
const assetBasePath = typeof assetBasePathOrTheme === 'string' ? assetBasePathOrTheme : 'assets';
this.theme = typeof assetBasePathOrTheme === 'string' ? theme : assetBasePathOrTheme;
this.assets = new Assets(assetBasePath);
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setSize(CW, CH, false);
@@ -130,6 +138,8 @@ export class ThreeRenderer implements Renderer {
this.syncEnemies(room, alpha);
this.syncTears(room, alpha);
this.syncSwing(game);
this.syncChest(room);
this.syncPickup(room, game.elapsedSteps + alpha);
this.updateEffects();
this.renderer.render(this.scene, this.camera);
@@ -239,18 +249,27 @@ export class ThreeRenderer implements Renderer {
const x = lerp(e.prevX, e.x, alpha);
const z = lerp(e.prevY, e.y, alpha);
const pop = 1 + 0.18 * (e.hitTimer / Math.max(1, MELEE.life)); // «дёргается» при попадании
const pop = 1 + 0.18 * (e.hitTimer / Math.max(1, MELEE.life));
const w = e.w * SPRITE_SCALE * pop;
const h = w * SPRITE_ASPECT;
v.sprite.scale.set(w, h, 1);
v.sprite.position.set(x, h / 2, z);
this.placeShadow(v.shadow, x, z, e.w);
// Искра в момент попадания (hitTimer вырос).
if (e.hitTimer > v.lastHit) {
this.spawnEffect(this.assets.spark(), this.theme.flash, x, e.w * 0.6, z, e.w * 0.9, 8, { vy: 0.6, grow: 1.06 });
// Увеличенный эффект для смены фазы босса (15+ тиков)
const size = e.hitTimer >= 12 ? e.w * 1.5 : e.w * 0.9;
const life = e.hitTimer >= 12 ? 16 : 8;
this.spawnEffect(this.assets.puff(), 0xff6600, x, e.w * 0.6, z, size, life, { vy: 0.8, grow: 1.06 });
}
v.lastHit = e.hitTimer;
// Цвет подкраски по фазе босса.
let phaseTint = 0xffffff;
if (e.burnTimer > 0) phaseTint = 0xff6644;
else if (e.type === 'boss' && e.phase === 2) phaseTint = 0xff8844;
else if (e.type === 'boss' && e.phase >= 3) phaseTint = 0xff3300;
(v.sprite.material as THREE.MeshBasicMaterial).color.setHex(phaseTint);
}
// Уборка: исчезнувшие враги. Если враг мёртв — «пуф» на месте гибели.
@@ -272,15 +291,21 @@ export class ThreeRenderer implements Renderer {
live.add(t);
let mesh = this.tearMeshes.get(t);
if (!mesh) {
const tex =
t.type === 'fireball' ? this.assets.fireball() :
t.type === 'beam' ? this.assets.sprite('beam') :
this.assets.tear();
mesh = new THREE.Mesh(this.vGeo, new THREE.MeshBasicMaterial({
map: this.assets.tear(), transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
map: tex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
}));
const s = PROJECTILE.radius * 4;
const s = t.type === 'fireball' ? PROJECTILE.radius * 5 :
t.type === 'beam' ? 50 :
PROJECTILE.radius * 4;
mesh.scale.set(s, s, 1);
this.scene.add(mesh);
this.tearMeshes.set(t, mesh);
}
mesh.position.set(lerp(t.prevX, t.x, alpha), TEAR_Y, lerp(t.prevY, t.y, alpha));
mesh.position.set(lerp(t.prevX, t.x, alpha), t.type === 'beam' ? 10 : TEAR_Y, lerp(t.prevY, t.y, alpha));
}
for (const [t, mesh] of this.tearMeshes) {
if (live.has(t)) continue;
@@ -296,7 +321,62 @@ export class ThreeRenderer implements Renderer {
this.swingMesh.visible = true;
this.swingMesh.position.set(s.box.x + s.box.w / 2, 2, s.box.y + s.box.h / 2);
this.swingMesh.scale.set(s.box.w * 1.4, s.box.h * 1.4, 1);
(this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / MELEE.life);
(this.swingMesh.material as THREE.MeshBasicMaterial).opacity = 0.8 * (s.life / s.maxLife);
}
private syncChest(room: Room): void {
if (room.chest?.alive) {
if (!this.chestMesh) {
const mat = new THREE.MeshBasicMaterial({
map: this.assets.sprite('chest'), transparent: true, alphaTest: 0.3, side: THREE.DoubleSide,
});
this.chestMesh = new THREE.Mesh(this.vGeo, mat);
this.scene.add(this.chestMesh);
}
const c = room.chest;
const w = c.w * 1.2;
const h = w * (64 / 48);
this.chestMesh.scale.set(w, h, 1);
this.chestMesh.position.set(c.x, h / 2, c.y);
} else if (this.chestMesh) {
this.scene.remove(this.chestMesh);
(this.chestMesh.material as THREE.Material).dispose();
this.chestMesh = null;
}
}
private syncPickup(room: Room, visualStep: number): void {
if (room.pickup) {
// Уникальный ключ для кэша меша: для оружия — его id, для предмета — префикс.
const pk = room.pickup;
const key = pk.kind === 'weapon' && pk.weaponId
? `w:${pk.weaponId}`
: `i:${pk.itemId ?? '?'}`;
if (!this.pickupMesh || this.currentPickupWeapon !== key) {
if (this.pickupMesh) {
this.scene.remove(this.pickupMesh);
(this.pickupMesh.material as THREE.Material).dispose();
}
const tex = pk.kind === 'weapon' && pk.weaponId
? this.assets.weaponIcon(pk.weaponId)
: this.assets.sprite('pickup');
const mat = new THREE.MeshBasicMaterial({
map: tex, transparent: true, alphaTest: 0.3, side: THREE.DoubleSide,
});
this.pickupMesh = new THREE.Mesh(this.vGeo, mat);
this.scene.add(this.pickupMesh);
this.currentPickupWeapon = key;
}
const w = pk.w * 1.6;
const h = w * (48 / 48);
this.pickupMesh.scale.set(w, h, 1);
this.pickupMesh.position.set(pk.x, h / 2 + Math.sin(visualStep / 12) * 3, pk.y);
} else if (this.pickupMesh) {
this.scene.remove(this.pickupMesh);
(this.pickupMesh.material as THREE.Material).dispose();
this.pickupMesh = null;
this.currentPickupWeapon = null;
}
}
// ── Эффекты (частицы-биллборды) ───────────────────────────
@@ -362,6 +442,8 @@ export class ThreeRenderer implements Renderer {
for (const m of this.tearMeshes.values()) (m.material as THREE.Material).dispose();
for (const fx of this.effects) (fx.mesh.material as THREE.Material).dispose();
(this.swingMesh.material as THREE.Material).dispose();
if (this.chestMesh) (this.chestMesh.material as THREE.Material).dispose();
if (this.pickupMesh) (this.pickupMesh.material as THREE.Material).dispose();
this.floorMat.dispose();
this.wallMat.dispose();
this.shadowMat.dispose();
+15
View File
@@ -0,0 +1,15 @@
/** Наборы ассетов. Логика игры про них не знает — это чисто настройка рендера/HUD. */
export type AssetPackId = 'classic' | 'binding-2';
export interface AssetPack {
id: AssetPackId;
name: string;
path: string;
}
export const ASSET_PACKS: readonly AssetPack[] = [
{ id: 'classic', name: 'Классика', path: 'assets' },
{ id: 'binding-2', name: 'Биндинг 2.0', path: 'assets-binding-2' },
];
export const DEFAULT_ASSET_PACK = ASSET_PACKS[0];
+214 -6
View File
@@ -1,20 +1,23 @@
import * as THREE from 'three';
import type { WeaponId } from '../core/weapons';
/**
* assets.ts — поставщик текстур. Каждая текстура грузится из
* `src/assets/<ключ>.png` через THREE.TextureLoader; если PNG нет — рисуется
* `<пак>/<ключ>.png` через THREE.TextureLoader; если PNG нет — рисуется
* процедурный фолбэк на canvas (функции drawX ниже), чтобы игра работала без
* ассетов. Текстуры кэшируются и освобождаются в dispose().
*
* Как заменить/добавить графику: положи PNG с именем `<ключ>.png` в `src/assets/`
* (dev-сервер отдаёт их из src/assets, прод-сборка копирует в dist/assets). Код
* Как заменить/добавить графику: положи PNG с именем `<ключ>.png` в папку пака
* (dev-сервер отдаёт их из src/<asset-pack>, прод-сборка копирует в dist/). Код
* трогать не нужно. Функции drawX — это лишь плейсхолдер-фолбэк; правь их, только
* если хочешь другой запасной рисунок. Полный список ключей — в docs/ASSET_BRIEF.md.
*/
export type SpriteKey =
| 'player-ranged' | 'player-melee'
| 'enemy-normal' | 'enemy-fast' | 'enemy-boss';
| 'enemy-normal' | 'enemy-fast' | 'enemy-boss'
| 'enemy-charger' | 'enemy-tank' | 'enemy-shooter'
| 'chest' | 'pickup' | 'fireball' | 'beam';
function canvas(w: number, h: number): { cv: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
const cv = document.createElement('canvas');
@@ -166,6 +169,183 @@ function drawDoor(open: boolean): HTMLCanvasElement {
return cv;
}
/** Сундук: тёмный ящик с золотым ободком. */
function drawChest(): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 48);
ctx.fillStyle = '#5a3a1a';
roundRect(ctx, 4, 4, 40, 40, 4);
ctx.fill();
ctx.strokeStyle = '#8a6a2a';
ctx.lineWidth = 3;
roundRect(ctx, 4, 4, 40, 40, 4);
ctx.stroke();
ctx.fillStyle = '#c9a84a';
ctx.fillRect(12, 18, 24, 10);
ctx.fillStyle = '#8a6a2a';
ctx.fillRect(22, 14, 4, 18);
ctx.fillStyle = '#3a220a';
ctx.beginPath(); ctx.arc(24, 24, 4, 0, Math.PI * 2); ctx.fill();
return cv;
}
/** Пикап оружия: парящий ромб со свечением. */
function drawPickup(): HTMLCanvasElement {
const { cv, ctx } = canvas(32, 32);
const g = ctx.createRadialGradient(16, 16, 1, 16, 16, 15);
g.addColorStop(0, '#ffdd88');
g.addColorStop(0.4, '#cc8822');
g.addColorStop(1, 'rgba(200,100,0,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 32, 32);
ctx.fillStyle = '#ffcc44';
ctx.beginPath();
ctx.moveTo(16, 4); ctx.lineTo(28, 16); ctx.lineTo(16, 28); ctx.lineTo(4, 16); ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#aa6600';
ctx.lineWidth = 1.5;
ctx.stroke();
return cv;
}
/** Зарядчик: красный, агрессивный вид, щель глаза. */
function drawCharger(): HTMLCanvasElement {
return drawCharacter({ head: '#cc4422', body: '#882211', outline: '#330a04', eye: '#ffaa00', small: false });
}
/** Танк: большой, тёмный, тяжёлый. */
function drawTank(): HTMLCanvasElement {
return drawCharacter({ head: '#554433', body: '#443322', outline: '#1a110a', eye: '#ff6622', horns: true });
}
/** Стрелок: синеватый, с «шапкой». */
function drawShooter(): HTMLCanvasElement {
return drawCharacter({ head: '#4488aa', body: '#336688', outline: '#122436', eye: '#aaddff', small: false });
}
/** Лазерный луч: яркая бело-голубая полоса. */
function drawBeam(): HTMLCanvasElement {
const { cv, ctx } = canvas(64, 64);
const g = ctx.createRadialGradient(32, 32, 2, 32, 32, 30);
g.addColorStop(0, '#ffffff');
g.addColorStop(0.2, '#88ddff');
g.addColorStop(0.5, '#4488ff');
g.addColorStop(1, 'rgba(0,50,200,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillRect(20, 28, 24, 8);
return cv;
}
/** Огненный шар: красно-оранжевый с бликом. */
function drawFireball(): HTMLCanvasElement {
const { cv, ctx } = canvas(32, 32);
const g = ctx.createRadialGradient(16, 16, 1, 16, 16, 14);
g.addColorStop(0, '#ffee88');
g.addColorStop(0.3, '#ff6622');
g.addColorStop(0.7, '#cc2200');
g.addColorStop(1, 'rgba(100,0,0,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(16, 16, 14, 0, Math.PI * 2); ctx.fill();
return cv;
}
/** Иконка оружия: слеза (голубая капля). */
function drawWeaponTears(): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 48);
const g = ctx.createRadialGradient(24, 24, 2, 24, 24, 20);
g.addColorStop(0, '#dff0ff'); g.addColorStop(0.5, '#6699cc'); g.addColorStop(1, 'rgba(40,80,140,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(24, 24, 20, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#aaccee'; ctx.beginPath(); ctx.arc(20, 18, 6, 0, Math.PI * 2); ctx.fill();
return cv;
}
/** Иконка оружия: кулак. */
function drawWeaponMelee(): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 48);
ctx.fillStyle = '#8a6a3a';
roundRect(ctx, 10, 14, 28, 24, 6); ctx.fill();
ctx.strokeStyle = '#4a2a0a'; ctx.lineWidth = 2; roundRect(ctx, 10, 14, 28, 24, 6); ctx.stroke();
ctx.fillStyle = '#6a4a1a'; ctx.fillRect(14, 20, 8, 8); ctx.fillRect(26, 20, 8, 8);
ctx.fillRect(18, 30, 12, 6);
ctx.fillStyle = '#5a3a0a'; ctx.fillRect(20, 6, 8, 12);
return cv;
}
/** Иконка оружия: дробовик — три точки. */
function drawWeaponShotgun(): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 48);
for (const [x, y] of [[24, 16], [16, 30], [32, 30]]) {
const g = ctx.createRadialGradient(x, y, 1, x, y, 10);
g.addColorStop(0, '#ffcc44'); g.addColorStop(0.5, '#cc6622'); g.addColorStop(1, 'rgba(150,60,0,0)');
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, 10, 0, Math.PI * 2); ctx.fill();
}
return cv;
}
/** Иконка оружия: топор. */
function drawWeaponAxe(): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 48);
ctx.fillStyle = '#777';
ctx.beginPath(); ctx.moveTo(8, 16); ctx.lineTo(38, 12); ctx.lineTo(40, 22); ctx.lineTo(30, 22); ctx.lineTo(30, 38); ctx.lineTo(16, 38); ctx.lineTo(16, 22); ctx.lineTo(6, 22); ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#333'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = '#5a3a0a'; ctx.fillRect(22, 34, 4, 12);
return cv;
}
/** Иконка оружия: посох. */
function drawWeaponStaff(): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 48);
ctx.fillStyle = '#6a4a2a'; ctx.fillRect(22, 6, 4, 36);
ctx.fillStyle = '#ff4422';
ctx.beginPath(); ctx.arc(24, 10, 10, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffcc44';
ctx.beginPath(); ctx.arc(24, 10, 5, 0, Math.PI * 2); ctx.fill();
return cv;
}
/** Иконка оружия: хлыст. */
function drawWeaponWhip(): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 48);
ctx.strokeStyle = '#8a6a3a'; ctx.lineWidth = 4; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(10, 38); ctx.quadraticCurveTo(18, 12, 38, 14); ctx.stroke();
ctx.strokeStyle = '#5a3a0a'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(10, 38); ctx.quadraticCurveTo(18, 12, 38, 14); ctx.stroke();
ctx.fillStyle = '#4a2a0a'; ctx.fillRect(6, 34, 8, 8);
return cv;
}
/** Иконка оружия: бомба. */
function drawWeaponBomb(): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 48);
ctx.fillStyle = '#333'; ctx.beginPath(); ctx.arc(24, 24, 16, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#555'; ctx.beginPath(); ctx.arc(24, 24, 10, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#cc4422'; ctx.fillRect(22, 4, 4, 8);
ctx.fillStyle = '#ff8844'; ctx.beginPath(); ctx.arc(24, 4, 4, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = '#222'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(24, 24, 16, 0, Math.PI * 2); ctx.stroke();
return cv;
}
/** Иконка оружия: бумеранг. */
function drawWeaponBoomerang(): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 48);
ctx.fillStyle = '#8a6a3a';
ctx.beginPath(); ctx.moveTo(8, 36); ctx.lineTo(22, 16); ctx.lineTo(40, 6); ctx.lineTo(38, 18); ctx.lineTo(22, 28); ctx.lineTo(18, 36); ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#4a2a0a'; ctx.lineWidth = 2; ctx.stroke();
ctx.fillStyle = '#a08850'; ctx.fillRect(8, 30, 12, 8);
return cv;
}
/** Иконка оружия: лазер. */
function drawWeaponLaser(): HTMLCanvasElement {
const { cv, ctx } = canvas(48, 48);
const g = ctx.createLinearGradient(8, 24, 40, 24);
g.addColorStop(0, 'rgba(100,180,255,0.2)'); g.addColorStop(0.3, '#88ddff'); g.addColorStop(0.5, '#ffffff'); g.addColorStop(0.7, '#88ddff'); g.addColorStop(1, 'rgba(100,180,255,0.2)');
ctx.fillStyle = g; ctx.fillRect(8, 18, 32, 12);
ctx.fillStyle = 'rgba(255,255,255,0.8)'; ctx.fillRect(12, 22, 24, 4);
return cv;
}
/** Мягкая тень-«пятно» под сущностью. */
function drawShadow(): HTMLCanvasElement {
const { cv, ctx } = canvas(64, 32);
@@ -184,9 +364,11 @@ export class Assets {
private cache = new Map<string, THREE.Texture>();
private readonly loader = new THREE.TextureLoader();
constructor(private readonly basePath = 'assets') {}
/**
* Возвращает текстуру по ключу. Сначала пытается загрузить PNG из
* `src/assets/<key>.png` (поставляется художником, см. docs/ASSET_BRIEF.md);
* `<basePath>/<key>.png` (поставляется художником, см. docs/ASSET_BRIEF.md);
* если файла нет — рисует процедурный фолбэк, чтобы игра не ломалась.
* 404 в консоли для ещё не добавленных ассетов — это норма (сработал фолбэк).
*/
@@ -195,7 +377,7 @@ export class Assets {
if (cached) return cached;
const tex = this.loader.load(
`assets/${key}.png`,
`${this.basePath}/${key}.png`,
undefined,
undefined,
() => { tex.image = build() as unknown as HTMLImageElement; tex.needsUpdate = true; }, // PNG нет → процедурный фолбэк
@@ -217,6 +399,13 @@ export class Assets {
case 'enemy-normal': return drawCharacter({ head: '#c08a5a', body: '#9a5a36', outline: '#3a2210', eye: '#2a1c0c' });
case 'enemy-fast': return drawCharacter({ head: '#bb3030', body: '#992222', outline: '#4a0e0e', eye: '#ffdddd', small: true });
case 'enemy-boss': return drawCharacter({ head: '#7a1414', body: '#5a0a0a', outline: '#250303', eye: '#ff4444', horns: true });
case 'enemy-charger': return drawCharger();
case 'enemy-tank': return drawTank();
case 'enemy-shooter': return drawShooter();
case 'chest': return drawChest();
case 'pickup': return drawPickup();
case 'fireball': return drawFireball();
case 'beam': return drawBeam();
}
});
}
@@ -229,6 +418,25 @@ export class Assets {
muzzle(): THREE.Texture { return this.get('muzzle', () => drawGlow('#fffbe0', 'rgba(255,200,60,0.7)'), false); }
spark(): THREE.Texture { return this.get('spark', () => drawGlow('#ffffff', 'rgba(255,230,170,0.6)'), false); }
puff(): THREE.Texture { return this.get('puff', () => drawGlow('rgba(220,220,230,0.9)', 'rgba(120,120,140,0.4)'), false); }
chest(): THREE.Texture { return this.get('chest', drawChest, false); }
pickup(): THREE.Texture { return this.get('pickup', drawPickup, false); }
fireball(): THREE.Texture { return this.get('fireball', drawFireball, false); }
weaponIcon(id: WeaponId): THREE.Texture {
return this.get(`weapon-icon-${id}`, () => {
switch (id) {
case 'tears': return drawWeaponTears();
case 'melee': return drawWeaponMelee();
case 'shotgun': return drawWeaponShotgun();
case 'axe': return drawWeaponAxe();
case 'staff': return drawWeaponStaff();
case 'whip': return drawWeaponWhip();
case 'bomb': return drawWeaponBomb();
case 'boomerang': return drawWeaponBoomerang();
case 'laser': return drawWeaponLaser();
}
});
}
dispose(): void {
for (const t of this.cache.values()) t.dispose();
+39 -2
View File
@@ -1,4 +1,5 @@
import type { LevelRules } from '../core/rules';
import { DEFAULT_ASSET_PACK, type AssetPack } from '../render/assetPacks';
/**
* Стартовое меню на DOM (поверх холстов). Показывает список пресетов-уровней;
@@ -10,12 +11,38 @@ import type { LevelRules } from '../core/rules';
*/
export class StartMenu {
private readonly root: HTMLElement;
private selectedPack: AssetPack;
constructor(root: HTMLElement, presets: LevelRules[], onStart: (rules: LevelRules) => void) {
constructor(
root: HTMLElement,
presets: LevelRules[],
assetPacks: readonly AssetPack[],
onStart: (rules: LevelRules, assetPack: AssetPack) => void,
) {
this.root = root;
this.selectedPack = assetPacks[0] ?? DEFAULT_ASSET_PACK;
const list = root.querySelector('#menu-presets');
if (!list) throw new Error('StartMenu: не найден #menu-presets внутри #menu');
const packs = document.createElement('div');
packs.className = 'menu-packs';
const packButtons: HTMLButtonElement[] = [];
for (const pack of assetPacks) {
const btn = document.createElement('button');
btn.className = 'menu-pack';
btn.type = 'button';
btn.textContent = pack.name;
btn.addEventListener('click', () => {
this.selectedPack = pack;
for (const b of packButtons) b.classList.toggle('is-selected', b === btn);
this.applyPackPreview();
});
packButtons.push(btn);
packs.appendChild(btn);
}
packButtons[0]?.classList.add('is-selected');
list.before(packs);
for (const rules of presets) {
const btn = document.createElement('button');
btn.className = 'menu-preset';
@@ -30,9 +57,11 @@ export class StartMenu {
desc.textContent = rules.description;
btn.append(name, desc);
btn.addEventListener('click', () => onStart(rules));
btn.addEventListener('click', () => onStart(rules, this.selectedPack));
list.appendChild(btn);
}
this.applyPackPreview();
}
show(): void {
@@ -42,4 +71,12 @@ export class StartMenu {
hide(): void {
this.root.style.display = 'none';
}
private applyPackPreview(): void {
const path = this.selectedPack.path;
const logo = this.root.querySelector<HTMLImageElement>('#menu-logo');
if (logo) logo.src = `${path}/logo.png`;
this.root.style.background =
`linear-gradient(rgba(10,10,15,0.7), rgba(10,10,15,0.82)), url(${path}/menu-bg.png) center / cover no-repeat, #0a0a0f`;
}
}
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect } from 'bun:test';
import { Game } from '../src/core/Game';
import { Rng } from '../src/core/rng';
import { Enemy } from '../src/core/entities/Enemy';
import { emptyInput, type InputState } from '../src/input/InputState';
import { DEFAULT_RULES } from '../src/core/rules';
import { MODE_RANGED, OX, OY, TILE, COLS } from '../src/config';
function input(patch: Partial<InputState> = {}): InputState {
return { ...emptyInput(), ...patch };
}
/** Ставит игру в normal-комнату с одним указанным врагом. */
function placeInCombatRoom(game: Game, e: Enemy): void {
for (const room of game.roomMap.rooms.values()) {
if (room.type !== 'normal') continue;
game.cc = room.c;
game.cr = room.r;
game.enterRoom('up');
room.enemies = [e];
room.cleared = false;
return;
}
throw new Error('normal-комната не найдена в карте');
}
describe('Combat (через Game.step)', () => {
it('снаряд ранит врага, на котором летит', () => {
const game = new Game(DEFAULT_RULES, new Rng(21));
// Враг прямо справа от игрока — стреляем вправо.
const enemy = new Enemy(game.player.x + 30, game.player.y, 'tank');
placeInCombatRoom(game, enemy);
const hp0 = enemy.hp;
game.step(input({ aimVec: { x: 1, y: 0 } }));
expect(enemy.hp).toBeLessThan(hp0);
});
it('огнемёт (staff) поджигает врага — урон продолжается после попадания', () => {
const game = new Game(DEFAULT_RULES, new Rng(22));
game.player.addWeapon('staff');
const enemy = new Enemy(game.player.x + 30, game.player.y, 'tank');
placeInCombatRoom(game, enemy);
game.step(input({ aimVec: { x: 1, y: 0 } })); // попадание = поджог
expect(enemy.burnTimer).toBeGreaterThan(0);
const hpAfterHit = enemy.hp;
// Несколько шагов без новых попаданий — урон от горения капает.
for (let i = 0; i < 30; i++) game.step(input());
expect(enemy.hp).toBeLessThan(hpAfterHit);
});
it('бомба даёт AoE-урон по нескольким врагам', () => {
const game = new Game(DEFAULT_RULES, new Rng(23));
game.player.addWeapon('bomb');
const near = new Enemy(game.player.x + 40, game.player.y, 'tank');
const far = new Enemy(game.player.x + 400, game.player.y, 'tank');
placeInCombatRoom(game, near);
game.curRoom.enemies.push(far);
const hpNear0 = near.hp;
const hpFar0 = far.hp;
game.step(input({ aimVec: { x: 1, y: 0 } }));
// Снаряд летит, ждём, пока он не исчезнет (долетит до стены и взорвётся).
for (let i = 0; i < 200; i++) game.step(input());
expect(near.hp).toBeLessThan(hpNear0);
expect(far.hp).toBe(hpFar0); // далеко — не задело
});
it('ближний бой (melee) наносит урон врагу в хитбоксе взмаха', () => {
const game = new Game(DEFAULT_RULES, new Rng(24));
// Дефолтный экипированный слот 0 = tears (ranged). Переключим на melee (слот 1).
game.player.equipped = 1;
game.player.mode = 1; // MODE_MELEE
// Враг прямо перед игроком (выше по Y).
const enemy = new Enemy(game.player.x, game.player.y - 30, 'tank');
placeInCombatRoom(game, enemy);
const hp0 = enemy.hp;
game.step(input({ aimVec: { x: 0, y: -1 } }));
expect(enemy.hp).toBeLessThan(hp0);
});
it(' лазер бьёт всех врагов в радиусе луча', () => {
const game = new Game(DEFAULT_RULES, new Rng(25));
game.player.addWeapon('laser');
const enemy = new Enemy(game.player.x + 50, game.player.y, 'tank');
placeInCombatRoom(game, enemy);
const hp0 = enemy.hp;
game.step(input({ aimVec: { x: 1, y: 0 } }));
// Луч стоит и жжёт несколько тиков.
for (let i = 0; i < 5; i++) game.step(input());
expect(enemy.hp).toBeLessThan(hp0);
});
});
describe('Combat — выстрел по сундуку', () => {
it('снаряд игрока ранит сундук в сокровищнице', () => {
const game = new Game(DEFAULT_RULES, new Rng(26));
// Ищем сокровищницу.
let treasureRoom: typeof game.curRoom | null = null;
for (const room of game.roomMap.rooms.values()) {
if (room.type === 'treasure') { treasureRoom = room; break; }
}
if (!treasureRoom) return; // на каком-то seed может не быть — тест пропустим мягко
game.cc = treasureRoom.c;
game.cr = treasureRoom.r;
game.enterRoom('up');
const chest = game.curRoom.chest!;
expect(chest).toBeDefined();
// Ставим игрока рядом с сундуком и стреляем вправо.
game.player.x = chest.x - 40;
game.player.y = chest.y;
const hp0 = chest.hp;
// Несколько выстрелов в сундук.
for (let i = 0; i < 10; i++) game.step(input({ aimVec: { x: 1, y: 0 } }));
expect(chest.hp).toBeLessThan(hp0);
});
});
describe('Splitter — распад при смерти', () => {
it('splitter умирает → спавнятся два fast', () => {
const game = new Game(DEFAULT_RULES, new Rng(27));
const e = new Enemy(game.player.x + 30, game.player.y, 'splitter');
placeInCombatRoom(game, e);
e.hp = 0; // убиваем напрямую
game.step(input());
// Ожидаем двух fast-детей (type !== splitter, должны быть 'fast').
const children = game.curRoom.enemies.filter((en) => en.type === 'fast');
expect(children.length).toBe(2);
// И сам splitter удалён (мёртвый).
expect(game.curRoom.enemies.some((en) => en.type === 'splitter')).toBe(false);
});
it('обычный враг при смерти НЕ плодит детей', () => {
const game = new Game(DEFAULT_RULES, new Rng(28));
const e = new Enemy(game.player.x + 30, game.player.y, 'normal');
placeInCombatRoom(game, e);
e.hp = 0;
game.step(input());
expect(game.curRoom.enemies.length).toBe(0);
});
});
+48 -1
View File
@@ -39,7 +39,7 @@ describe('Game', () => {
it('стрельба создаёт снаряд, который потом исчезает', () => {
const game = new Game(DEFAULT_RULES, new Rng(4));
game.step(input({ aimDir: 'right' }));
game.step(input({ aimVec: { x: 1, y: 0 } }));
expect(game.curRoom.tears.length).toBe(1);
// Снаряд летит вправо и со временем гаснет (стена/время жизни).
for (let i = 0; i < 200; i++) game.step(input());
@@ -133,4 +133,51 @@ describe('Game', () => {
expect(game.cc).toBe(0);
expect(game.cr).toBe(0);
});
it('РЕГРЕССИЯ: бесконечный спуск применяет усиление врагов при спавне комнат', () => {
const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 100 };
const game = new Game(endless);
for (let targetFloor = 2; targetFloor <= 5; targetFloor++) {
const bossRoom = [...game.roomMap.rooms.values()].find((room) => room.type === 'boss')!;
bossRoom.cleared = true;
game.step(input());
expect(game.floor).toBe(targetFloor);
}
const bossRoom = [...game.roomMap.rooms.values()].find((room) => room.type === 'boss')!;
game.cc = bossRoom.c;
game.cr = bossRoom.r;
game.enterRoom('up');
const boss = bossRoom.enemies.find((e) => e.type === 'boss')!;
expect(boss.maxHp).toBeGreaterThan(10);
});
it('снаряд берёт урон из выбранного оружия', () => {
const game = new Game(DEFAULT_RULES, new Rng(12));
game.player.addWeapon('boomerang');
game.step(input({ aimVec: { x: 1, y: 0 } }));
expect(game.curRoom.tears[0].type).toBe('boomerang');
expect(game.curRoom.tears[0].damage).toBe(2);
});
it('РЕГРЕССИЯ: эффект снаряда не зависит от смены оружия после выстрела', () => {
const game = new Game(DEFAULT_RULES, new Rng(13));
game.player.addWeapon('staff');
const enemy = new Enemy(game.player.x + 7, game.player.y, 'tank');
game.curRoom.enemies = [enemy];
game.step(input({ aimVec: { x: 1, y: 0 } }));
expect(enemy.burnTimer).toBeGreaterThan(0);
const hpAfterHit = enemy.hp;
game.player.addWeapon('tears');
for (let i = 0; i < 10; i++) game.step(input());
expect(enemy.hp).toBeLessThan(hpAfterHit);
});
});
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'bun:test';
import { Game } from '../src/core/Game';
import { Rng } from '../src/core/rng';
import { emptyInput, type InputState } from '../src/input/InputState';
import { DEFAULT_RULES } from '../src/core/rules';
import { ITEMS, applyItem, ALL_ITEM_IDS } from '../src/core/items';
import { NEUTRAL_STATS } from '../src/core/entities/Player';
import { Pickup } from '../src/core/entities/Pickup';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
function input(patch: Partial<InputState> = {}): InputState {
return { ...emptyInput(), ...patch };
}
describe('Предметы', () => {
it('applyItem модифицирует статы дельтой', () => {
const s = { ...NEUTRAL_STATS };
applyItem(s, ITEMS['cricket-head'], () => {});
expect(s.damageMul).toBeCloseTo(1.5, 5);
expect(s.fireRateMul).toBe(1); // не должно было измениться
applyItem(s, ITEMS['sad-onion'], () => {});
expect(s.fireRateMul).toBeCloseTo(1.35, 5);
// Стак того же предмета.
applyItem(s, ITEMS['cricket-head'], () => {});
expect(s.damageMul).toBeCloseTo(2.0, 5);
});
it('applyItem с maxHpBonus растит HP через колбэк', () => {
let hp = 6, maxHp = 6;
applyItem({ ...NEUTRAL_STATS }, ITEMS['blood-penny'], (bonus) => {
maxHp += bonus;
hp = Math.min(maxHp, hp + bonus);
});
expect(maxHp).toBe(8);
expect(hp).toBe(8);
});
it('ALL_ITEM_IDS содержит все предметы из ITEMS', () => {
expect(ALL_ITEM_IDS.length).toBe(Object.keys(ITEMS).length);
});
});
describe('Подбор предмета через Game', () => {
it('предмет лежит на полу, игрок его подбирает — статы меняются', () => {
const game = new Game(DEFAULT_RULES, new Rng(71));
// Найдём сокровищницу или просто normal — главное, чтобы у комнаты был pickup.
const room = game.curRoom;
const before = { damageMul: game.player.stats.damageMul };
room.pickup = Pickup.item(game.player.x, game.player.y, 'cricket-head');
game.step(input());
expect(room.pickup).toBeNull();
expect(game.player.stats.damageMul).toBeGreaterThan(before.damageMul);
});
it('предмет-лечение растит maxHp', () => {
const game = new Game(DEFAULT_RULES, new Rng(72));
const room = game.curRoom;
const maxHp0 = game.player.maxHp;
const hp0 = game.player.hp;
room.pickup = Pickup.item(game.player.x, game.player.y, 'blood-penny');
game.step(input());
expect(game.player.maxHp).toBe(maxHp0 + 2);
expect(game.player.hp).toBe(Math.min(maxHp0 + 2, hp0 + 2));
});
it('сундук может дропнуть как оружие, так и предмет', () => {
// На нескольких seed'ах должно выпадать хотя бы по разу каждого типа.
let weaponDrops = 0;
let itemDrops = 0;
for (let seed = 1; seed <= 50; seed++) {
const game = new Game(DEFAULT_RULES, new Rng(seed));
// Найдём сокровищницу.
let treasure = null;
for (const r of game.roomMap.rooms.values()) {
if (r.type === 'treasure') { treasure = r; break; }
}
if (!treasure) continue;
game.cc = treasure.c;
game.cr = treasure.r;
game.enterRoom('up');
const chest = game.curRoom.chest!;
chest.hp = 0;
for (let i = 0; i < 3; i++) game.step(input());
if (!game.curRoom.pickup) continue;
if (game.curRoom.pickup.kind === 'weapon') weaponDrops++;
else itemDrops++;
}
expect(weaponDrops).toBeGreaterThan(0);
expect(itemDrops).toBeGreaterThan(0);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'bun:test';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
import { Room } from '../src/core/world/Room';
import { moveEntity, type Movable } from '../src/core/systems/movement';
/** Минимальный movable, на котором удобно тестировать moveEntity. */
function box(x: number, y: number, size = 20): Movable & { x: number; y: number } {
const obj: Movable & { x: number; y: number } = {
x, y,
get box() {
return { x: this.x - size / 2, y: this.y - size / 2, w: size, h: size };
},
};
return obj;
}
/** Свежая зачищенная комната: двери открыты, по периметру стены. */
function makeRoom() {
const r = new Room(0, 0, 'spawn');
r.cleared = true;
r.rebuildTiles();
return r;
}
describe('moveEntity', () => {
it('свободно двигается по полу', () => {
const room = makeRoom();
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
const e = box(cx, cy);
moveEntity(e, 10, 5, room);
expect(e.x).toBe(cx + 10);
expect(e.y).toBe(cy + 5);
});
it('не проходит сквозь стену по X, но Y применяется (скольжение)', () => {
const room = makeRoom();
const x = OX + 3 * TILE;
const y = OY + (ROWS / 2) * TILE;
const e = box(x, y, 16);
// Большой рывок влево — хитбокс вылетит за col=0, isBlocked вернётся true.
moveEntity(e, -500, 10, room);
expect(e.x).toBe(x); // откатилось
expect(e.y).toBe(y + 10); // применилось
});
it('не проходит сквозь стену по Y, X применяется', () => {
const room = makeRoom();
const x = OX + (COLS / 2) * TILE;
const y = OY + 3 * TILE;
const e = box(x, y, 16);
moveEntity(e, 7, -500, room);
expect(e.x).toBe(x + 7);
expect(e.y).toBe(y);
});
it('ни X, ни Y не применяются, если в углу', () => {
const room = makeRoom();
const x = OX + 3 * TILE;
const y = OY + 3 * TILE;
const e = box(x, y, 16);
moveEntity(e, -200, -200, room);
expect(e.x).toBe(x);
expect(e.y).toBe(y);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect } from 'bun:test';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
import { Room } from '../src/core/world/Room';
import { Projectile } from '../src/core/entities/Projectile';
import { Enemy } from '../src/core/entities/Enemy';
import {
applyWeaponProjectileStats,
explodeBomb,
projectileHitWall,
} from '../src/core/systems/projectiles';
import { WEAPONS } from '../src/core/weapons';
import { NEUTRAL_STATS } from '../src/core/entities/Player';
const S = NEUTRAL_STATS;
function makeRoom() {
const r = new Room(0, 0, 'normal');
r.cleared = true;
r.rebuildTiles();
return r;
}
describe('applyWeaponProjectileStats', () => {
it('копирует урон и параметры горения из оружия', () => {
const t = new Projectile(0, 0, 1, 0, 'fireball');
applyWeaponProjectileStats(t, WEAPONS.staff, S);
expect(t.damage).toBe(WEAPONS.staff.damage);
expect(t.burnDamage).toBe(WEAPONS.staff.fireDmg);
expect(t.burnInterval).toBe(WEAPONS.staff.fireInterval);
expect(t.burnDuration).toBe(WEAPONS.staff.fireDuration);
});
it('для бомбы выставляет explosionRadius', () => {
const t = new Projectile(0, 0, 1, 0, 'bomb');
applyWeaponProjectileStats(t, WEAPONS.bomb, S);
expect(t.explosionRadius).toBe(WEAPONS.bomb.explosionRadius);
});
it('для обычной слезы горение остаётся 0', () => {
const t = new Projectile(0, 0, 1, 0, 'tear');
applyWeaponProjectileStats(t, WEAPONS.tears, S);
expect(t.burnDuration).toBe(0);
});
it('damageMul множит урон и горение', () => {
const t = new Projectile(0, 0, 1, 0, 'fireball');
applyWeaponProjectileStats(t, WEAPONS.staff, { ...S, damageMul: 2 });
expect(t.damage).toBe(WEAPONS.staff.damage * 2);
expect(t.burnDamage).toBe((WEAPONS.staff.fireDmg ?? 1) * 2);
});
it('shotSpeedMul множит скорость; rangeMul множит жизнь', () => {
const t = new Projectile(0, 0, 1, 0, 'tear');
const baseLife = t.life;
const baseSpeed = t.speed;
applyWeaponProjectileStats(t, WEAPONS.tears, { ...S, shotSpeedMul: 1.5, rangeMul: 2 });
expect(t.speed).toBeCloseTo(baseSpeed * 1.5, 5);
expect(t.life).toBe(baseLife * 2);
});
});
describe('projectileHitWall', () => {
it('ловит стену по краю', () => {
const room = makeRoom();
const t = new Projectile(OX + TILE - 1, OY + (ROWS / 2) * TILE, 0, 0, 'tear');
expect(projectileHitWall(t, room)).toBe(true);
});
it('пропускает центр пола', () => {
const room = makeRoom();
const t = new Projectile(OX + (COLS / 2) * TILE, OY + (ROWS / 2) * TILE, 0, 0, 'tear');
expect(projectileHitWall(t, room)).toBe(false);
});
});
describe('explodeBomb', () => {
it('не делает ничего для не-бомбы', () => {
const room = makeRoom();
const t = new Projectile(0, 0, 0, 0, 'tear');
const e = new Enemy(OX + 100, OY + 100, 'normal');
const hpBefore = e.hp;
room.enemies = [e];
explodeBomb(room, t);
expect(e.hp).toBe(hpBefore);
});
it('бомба бьёт врагов в радиусе и отбрасывает', () => {
const room = makeRoom();
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
const t = new Projectile(cx, cy, 0, 0, 'bomb');
t.explosionRadius = 80;
const e = new Enemy(cx + 10, cy + 10, 'normal');
const hpBefore = e.hp;
room.enemies = [e];
explodeBomb(room, t);
expect(e.hp).toBeLessThan(hpBefore);
expect(Math.abs(e.knx) + Math.abs(e.kny)).toBeGreaterThan(0);
});
it('бомба НЕ бьёт врагов за пределами радиуса', () => {
const room = makeRoom();
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
const t = new Projectile(cx, cy, 0, 0, 'bomb');
t.explosionRadius = 40;
const e = new Enemy(cx + 500, cy + 500, 'normal'); // далеко
const hpBefore = e.hp;
room.enemies = [e];
explodeBomb(room, t);
expect(e.hp).toBe(hpBefore);
});
});
+271
View File
@@ -0,0 +1,271 @@
import { describe, it, expect } from 'bun:test';
import { Game } from '../src/core/Game';
import { Rng } from '../src/core/rng';
import { Enemy } from '../src/core/entities/Enemy';
import { Projectile } from '../src/core/entities/Projectile';
import { emptyInput, type InputState } from '../src/input/InputState';
import { DEFAULT_RULES, PRESETS } from '../src/core/rules';
import { OX, OY, TILE, COLS, ROWS } from '../src/config';
function input(patch: Partial<InputState> = {}): InputState {
return { ...emptyInput(), ...patch };
}
/** Ставит игру в normal-комнату с указанными врагами. */
function placeInNormalRoom(game: Game, enemies: Enemy[]): void {
for (const room of game.roomMap.rooms.values()) {
if (room.type !== 'normal') continue;
game.cc = room.c;
game.cr = room.r;
game.enterRoom('up');
room.enemies = enemies;
room.cleared = false;
return;
}
throw new Error('normal-комната не найдена');
}
describe('Регрессии Wave 1', () => {
it('мёртвые враги удаляются из room.enemies (нет утечки)', () => {
const game = new Game(DEFAULT_RULES, new Rng(31));
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
// Три врага, по 1 hp — умрут почти сразу от попадания.
const e1 = new Enemy(cx - 50, cy, 'normal');
const e2 = new Enemy(cx + 50, cy, 'normal');
const e3 = new Enemy(cx, cy - 50, 'normal');
e1.hp = e2.hp = e3.hp = 1;
placeInNormalRoom(game, [e1, e2, e3]);
// Чтобы не полагаться на полёт снарядов — убиваем напрямую и прогоняем шаг.
e1.hp = 0; e2.hp = 0; e3.hp = 0;
game.step(input());
expect(game.curRoom.enemies.length).toBe(0); // фильтранулось, а не осталось 3 мёртвых
});
it('РЕГРЕССИЯ: после убийства всех врагов комната становится cleared (двери откроются)', () => {
const game = new Game(DEFAULT_RULES, new Rng(34));
const cx = OX + (COLS / 2) * TILE;
const cy = OY + (ROWS / 2) * TILE;
const e = new Enemy(cx, cy, 'normal');
placeInNormalRoom(game, [e]);
expect(game.curRoom.cleared).toBe(false);
// Убиваем и прогоняем шаг — фильтр не должен помешать cleared стать true.
e.hp = 0;
game.step(input());
expect(game.curRoom.cleared).toBe(true);
expect(game.curRoom.enemies.length).toBe(0);
});
it('Player.addWeapon использует MODE_MELEE для melee-оружия', () => {
// Проверяем, что mode === MODE_MELEE (1), а не литерал 1 по ошибке.
const game = new Game(DEFAULT_RULES, new Rng(32));
game.player.addWeapon('axe'); // melee
expect(game.player.mode).toBe(1);
game.player.addWeapon('tears'); // ranged
expect(game.player.mode).toBe(0);
});
it('equipSlot молча игнорирует несуществующие слоты', () => {
const game = new Game(DEFAULT_RULES, new Rng(33));
const equippedBefore = game.player.equipped;
game.equipSlot(5); // не существует
expect(game.player.equipped).toBe(equippedBefore);
game.equipSlot(-1);
expect(game.player.equipped).toBe(equippedBefore);
});
it('WeaponDef.spread/beamRange вынесены из хардкода и читаются оружием', () => {
const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 999 };
const game = new Game(endless);
game.player.addWeapon('laser');
// Выстрел — луч должен оказаться на beamRange от игрока (70 по умолчанию).
const px = game.player.x;
game.step(input({ aimVec: { x: 1, y: 0 } }));
const beam = game.curRoom.tears.find((t) => t.type === 'beam');
expect(beam).toBeDefined();
expect(beam!.x).toBe(px + 70); // если не вынесли в WeaponDef — будет undefined → NaN
});
});
describe('Босс (milestone, этаж 5/10/15)', () => {
it('босс не плодит миньёнов сверх BOSS.maxMinions', () => {
const endless = { ...PRESETS.find((p) => p.id === 'endless')!, seed: 505 };
const game = new Game(endless);
// Домотать до 5-го этажа.
for (let i = 2; i <= 5; i++) {
const bossRoom = [...game.roomMap.rooms.values()].find((r) => r.type === 'boss')!;
bossRoom.cleared = true;
game.step(input());
}
expect(game.floor).toBe(5);
// Заходим в комнату босса.
const bossRoom = [...game.roomMap.rooms.values()].find((r) => r.type === 'boss')!;
game.cc = bossRoom.c;
game.cr = bossRoom.r;
game.enterRoom('up');
const boss = bossRoom.enemies.find((e) => e.type === 'boss')!;
expect(boss).toBeDefined();
// Опускаем HP до фазы 3 (< 33%), чтобы начался спавн миньёнов.
boss.hp = Math.floor(boss.maxHp * 0.1);
boss.spawnTimer = 0; // принудительно вызываем спавн в ближайший шаг
// Прогоняем много шагов, чтобы спавн многократно сработал.
for (let i = 0; i < 5000; i++) game.step(input());
const aliveEnemies = bossRoom.enemies.filter((e) => e.alive);
// Миньёны — все живые, кроме самого босса.
expect(aliveEnemies.length).toBeLessThanOrEqual(5); // босс + 4 миньёна максимум
});
});
describe('Переход между комнатами', () => {
it('игрок зачищает комнату и переходит в соседнюю', () => {
const game = new Game(DEFAULT_RULES, new Rng(41));
const spawnRoom = game.curRoom;
expect(spawnRoom.type).toBe('spawn');
// Берём первое доступное направление дверей у спавна.
const dir = (['up', 'down', 'left', 'right'] as const).find((d) => spawnRoom.doors[d]);
expect(dir).toBeDefined();
const cc0 = game.cc;
const cr0 = game.cr;
// Ставим игрока ОДИН ТАЙЛ ВНУТРЬ от двери (как делает enterRoom): тогда по
// ходу движения к двери он несколько шагов проведёт в крайнем тайле и
// checkTransition успеет сработать. (Старт прямо в door row был бы сразу
// вытолкнут за пределы комнаты.)
const center = doorNeighborCell(dir!);
game.player.place(center.x, center.y);
game.player.transCD = 0;
const move = {
up: { moveX: 0, moveY: -1 },
down: { moveX: 0, moveY: 1 },
left: { moveX: -1, moveY: 0 },
right: { moveX: 1, moveY: 0 },
}[dir!];
for (let i = 0; i < 80; i++) game.step(input(move));
expect(game.cc !== cc0 || game.cr !== cr0).toBe(true);
});
});
/** Тайл ВНУТРИ комнаты напротив двери (один шаг от двери). */
function doorNeighborCell(dir: 'up' | 'down' | 'left' | 'right'): { x: number; y: number } {
// Центр двери + один тайл внутрь.
const cx = 7.5;
const cy = 5.5;
switch (dir) {
case 'up': return { x: OX + cx * TILE, y: OY + 1.5 * TILE };
case 'down': return { x: OX + cx * TILE, y: OY + (ROWS - 1.5) * TILE };
case 'left': return { x: OX + 1.5 * TILE, y: OY + cy * TILE };
case 'right': return { x: OX + (COLS - 1.5) * TILE, y: OY + cy * TILE };
}
}
describe('Сундук → пикап → экипировка', () => {
it('сундук после уничтожения выпадает пикап оружия, который подбирается', () => {
const game = new Game(DEFAULT_RULES, new Rng(51));
// Найдём сокровищницу.
let treasure: typeof game.curRoom | null = null;
for (const room of game.roomMap.rooms.values()) {
if (room.type === 'treasure') { treasure = room; break; }
}
if (!treasure) return; // мягко пропускаем, если на этом seed нет
game.cc = treasure.c;
game.cr = treasure.r;
game.enterRoom('up');
const chest = game.curRoom.chest!;
const weaponBefore = game.player.weapons[game.player.equipped].id;
// Убиваем сундук напрямую и прогоняем несколько шагов: должен заспавнить пикап.
chest.hp = 0;
for (let i = 0; i < 5; i++) game.step(input());
expect(game.curRoom.pickup).not.toBeNull();
// Ставим игрока на пикап и прогоняем шаги — должен подобрать.
game.player.place(game.curRoom.pickup!.x, game.curRoom.pickup!.y);
for (let i = 0; i < 5; i++) game.step(input());
expect(game.curRoom.pickup).toBeNull();
expect(game.player.weapons[game.player.equipped].id).not.toBe(weaponBefore);
});
});
describe('Secret room (секретка)', () => {
it('спавнится на карте с шансом — среди 50 seed’ов хотя бы раз', () => {
let found = 0;
for (let seed = 1; seed <= 50; seed++) {
const game = new Game(DEFAULT_RULES, new Rng(seed));
for (const room of game.roomMap.rooms.values()) {
if (room.type === 'secret') { found++; break; }
}
}
expect(found).toBeGreaterThan(0);
});
it('при первом входе даёт +1 max HP; при повторном — НЕ даёт', () => {
// Найдём seed с секреткой.
let seed = 1;
let game = new Game(DEFAULT_RULES, new Rng(seed));
while (![...game.roomMap.rooms.values()].some(r => r.type === 'secret') && seed < 200) {
seed++;
game = new Game(DEFAULT_RULES, new Rng(seed));
}
if (seed >= 200) return; // мягко пропускаем
const secret = [...game.roomMap.rooms.values()].find(r => r.type === 'secret')!;
const maxHp0 = game.player.maxHp;
game.cc = secret.c; game.cr = secret.r;
game.enterRoom('up');
expect(game.player.maxHp).toBe(maxHp0 + 1);
// Выйдем и зайдём снова — бонуса быть не должно.
game.cc = 0; game.cr = 0; // возвращаемся в спавн
game.enterRoom('up');
const maxHp1 = game.player.maxHp;
game.cc = secret.c; game.cr = secret.r;
game.enterRoom('up');
expect(game.player.maxHp).toBe(maxHp1);
});
it('в секретке нет врагов (как в сокровищнице) — зачищается сразу', () => {
let seed = 1;
let game = new Game(DEFAULT_RULES, new Rng(seed));
while (![...game.roomMap.rooms.values()].some(r => r.type === 'secret') && seed < 200) {
seed++;
game = new Game(DEFAULT_RULES, new Rng(seed));
}
if (seed >= 200) return;
const secret = [...game.roomMap.rooms.values()].find(r => r.type === 'secret')!;
game.cc = secret.c; game.cr = secret.r;
game.enterRoom('up');
expect(game.curRoom.enemies.length).toBe(0);
expect(game.curRoom.cleared).toBe(true);
});
});
/** Тайл ВНУТРИ комнаты напротив двери (один шаг от двери). */
function doorNeighborCell(dir: 'up' | 'down' | 'left' | 'right'): { x: number; y: number } {
// Центр двери + один тайл внутрь.
const cx = 7.5;
const cy = 5.5;
switch (dir) {
case 'up': return { x: OX + cx * TILE, y: OY + 1.5 * TILE };
case 'down': return { x: OX + cx * TILE, y: OY + (ROWS - 1.5) * TILE };
case 'left': return { x: OX + 1.5 * TILE, y: OY + cy * TILE };
case 'right': return { x: OX + (COLS - 1.5) * TILE, y: OY + cy * TILE };
}
}
+17
View File
@@ -41,4 +41,21 @@ describe('Rng', () => {
const shuffled = r.shuffle([...arr]);
expect([...shuffled].sort()).toEqual(arr);
});
it('авто-seed не вызывает Math.random()', () => {
const math = Math as Math & { random: () => number };
const original = math.random;
math.random = () => { throw new Error('Math.random вызван'); };
try {
const r = new Rng();
expect(r.next()).toBeGreaterThanOrEqual(0);
expect(r.next()).toBeLessThan(1);
} finally {
math.random = original;
}
});
it('pick явно ругается на пустой массив', () => {
expect(() => new Rng(1).pick([])).toThrow('пустой массив');
});
});
+31
View File
@@ -4,6 +4,17 @@ import { Room } from '../src/core/world/Room';
import { Rng } from '../src/core/rng';
import { OX, OY, TILE, DOOR, SPAWN } from '../src/config';
import { dist } from '../src/core/util';
import { DEFAULT_RULES, type LevelRules } from '../src/core/rules';
const denseRules: LevelRules = {
...DEFAULT_RULES,
id: 'dense-test',
name: 'Тестовая тесная комната',
description: 'Много врагов, чтобы проверить отказ от плохих позиций спавна.',
map: { ...DEFAULT_RULES.map },
player: { ...DEFAULT_RULES.player },
enemies: { ...DEFAULT_RULES.enemies, densityMul: 40 },
};
describe('spawner', () => {
it('в обычной комнате врагов в ожидаемом диапазоне', () => {
@@ -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);
}
}
});
});