diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8df066b --- /dev/null +++ b/AGENTS.md @@ -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, пока этого не попросили (рендер для этого + готов — он изолирован, — но это отдельная большая задача). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3208706..6f741d7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 шагов/с. diff --git a/src/config.ts b/src/config.ts index 4f57033..aad3df4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, // блок повторного перехода между комнатами, в шагах @@ -137,6 +143,7 @@ export const SPAWN = { minDistFromDoor: 180, // не спавнить ближе к двери входа minDistFromPlayer: 150, minDistBetween: 60, + maxPlacementTries: 100, treasureChance: 0.12, // шанс комнаты-сокровищницы bossChance: 0.2, // шанс назначить комнату боссом }; diff --git a/src/core/Game.ts b/src/core/Game.ts index ff2a4f4..b3b954b 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -128,7 +128,7 @@ 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; diff --git a/src/core/rng.ts b/src/core/rng.ts index ce1a74d..134e79c 100644 --- a/src/core/rng.ts +++ b/src/core/rng.ts @@ -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(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; +} diff --git a/src/core/systems/spawner.ts b/src/core/systems/spawner.ts index 3bc7c8d..3e095d7 100644 --- a/src/core/systems/spawner.ts +++ b/src/core/systems/spawner.ts @@ -6,6 +6,23 @@ import type { Dir, EnemyType } from '../types'; import type { Rng } from '../rng'; import { DEFAULT_RULES, type LevelRules } from '../rules'; +function isSpawnSpotClear( + x: number, + y: number, + doorX: number, + doorY: number, + playerX: number, + playerY: number, + enemies: readonly Enemy[], +): boolean { + if (dist(x, y, doorX, doorY) < SPAWN.minDistFromDoor) return false; + if (dist(x, y, playerX, playerY) < SPAWN.minDistFromPlayer) return false; + for (const e of enemies) { + if (dist(x, y, e.x, e.y) < SPAWN.minDistBetween) return false; + } + return true; +} + /** * Подбирает врагов для комнаты и расставляет их так, чтобы они не появились * вплотную к двери входа, к игроку или друг к другу. Число, тип и сила врагов @@ -43,19 +60,12 @@ export function spawnEnemies( let x = 0; let 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)); } diff --git a/src/engine/GameLoop.ts b/src/engine/GameLoop.ts index cae4fec..9dd8e5d 100644 --- a/src/engine/GameLoop.ts +++ b/src/engine/GameLoop.ts @@ -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); diff --git a/src/input/InputState.ts b/src/input/InputState.ts index 1cb5f8c..8c8915a 100644 --- a/src/input/InputState.ts +++ b/src/input/InputState.ts @@ -19,6 +19,11 @@ export interface InputState { restart: boolean; // рестарт на экране конца игры (однократно) } +/** Любой источник ввода для игрового цикла: клавиатура, геймпад, бот, тест. */ +export interface InputSource { + poll(): InputState; +} + /** Нейтральный снимок — ничего не нажато. */ export function emptyInput(): InputState { return { diff --git a/src/input/KeyboardController.ts b/src/input/KeyboardController.ts index b39702f..1fd78c5 100644 --- a/src/input/KeyboardController.ts +++ b/src/input/KeyboardController.ts @@ -1,4 +1,4 @@ -import type { InputState } from './InputState'; +import type { InputSource, InputState } from './InputState'; import type { Dir } from '../core/types'; /** @@ -14,7 +14,7 @@ import type { Dir } from '../core/types'; * (смена оружия/рестарт). Раз в кадр вызывается poll(), который собирает * InputState и сбрасывает однократные флаги. */ -export class KeyboardController { +export class KeyboardController implements InputSource { private held = new Set(); private toggleWeaponEdge = false; private restartEdge = false; diff --git a/tests/rng.test.ts b/tests/rng.test.ts index 5b5e308..36d3a7f 100644 --- a/tests/rng.test.ts +++ b/tests/rng.test.ts @@ -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('пустой массив'); + }); }); diff --git a/tests/spawner.test.ts b/tests/spawner.test.ts index 5b306f5..96c8bd1 100644 --- a/tests/spawner.test.ts +++ b/tests/spawner.test.ts @@ -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); + } + } + }); });