diff --git a/docs/plan.md b/docs/plan.md index cecffe2..be566f9 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -53,6 +53,7 @@ | 31 | v2 трек 10: инвентарь — жанронезависимый контейнер предметов (перенос v1), эффекты диалогов giveItem/takeItem исполняются игрой, HUD сумки; демка: страж даёт/забирает цветок, выбор виден по hasItem + гейт | ✅ (2026-09-09). `inventory/inventory.ts` — перенос Inventory v1 1:1: счётчики по id (Map), лимиты слотов/стака обрезают и возвращают факт (модель не бросает), `onChange` с деталями изменения (add/remove/clear/load), serialize/load (мусор игнорируется, кламп под лимиты), `inventoryFromData` для сейвов. Демка: сумка героя (maxSlots 8), `DialogueWorld.hasItem` смотрит в неё (выбор «Отдать цветок» виден только с цветком), `onEffect` исполняет giveItem/takeItem (движок эмитит — исполнение игрой), HUD `#inv` в углу перерисовывается по событию контейнера (не из тика — надёжно), ITEM_NAMES для отображения. Гейт `inventory.mjs` 8 проверок: сумка пуста на старте, скрытый/видимый выбор по hasItem, giveItem наполнил (цветок ×1), HUD рисуется (зона угла кадра), takeItem забрал (флаг flowerGivenAway), HUD скрылся (diff 0). 147 юнит-тестов. Урок: **перерисовка HUD по событию контейнера, а не из тика** — Inventory.onChange синхронен, изменения из моста/кнопок применяются сразу. | | 32 | v2 трек 11: сохранение — SaveManager (JSON-слоты, StorageLike инъекцией, мета в параллельном ключе), сериализация состояния демки (герой/флаги/переменные/сумка), мост saveTo/loadFrom/saveList/saveDelete + гейт | ✅ (2026-09-09). `save/saveManager.ts` — перенос SaveManager v1 1:1: `save(slot, data, meta?)`/`load` (битый JSON → null)/`slotMeta`/`list`/`delete`/`has`/`listSlots`; хранилище — `StorageLike` инъекцией (демка — window.localStorage, тесты — Map-заглушка), мета — в параллельном ключе `meta:` (старые сейвы читаемы). Демка: `DemoSave {version, heroPos, heroYaw, heroPhase, flags, vars, inv}` — `serializeSave`/`applySave` (версия формата проверяется; активный диалог прерывается; HUD-подсказка обновляется сразу — урок трека 9); мост `saveTo(slot, title)`/`loadFrom(slot)`/`saveList()`/`saveDelete(slot)`. Гейт `save.mjs` 9 проверок: слотов нет на старте, saveTo пишет слот+мету, состояние ломается (отдача цветка + уезд героя), loadFrom восстанавливает позицию/флаги (без flowerGivenAway)/сумку (цветок ×1), **кадр после загрузки попиксельно равен кадру сохранения (diff 0)**, saveDelete чистит. 154 юнит-теста. Уроки: **в ручном режиме кадр рендерится только в stepTicks** — после loadFrom-мостов нужен тик перед скриншотом (иначе скриншот захватывает протухший канвас с прежней камерой); **applySave меняет состояние вне тика — обновлять HUD сразу** (тот же урок, что у диалогов). | | 33 | v2 трек 12: сцены/переходы — стек SceneManager (меню поверх игры), фейд-переходы out→swap→in, параллельные переходы глотаются; демка: DOM-меню (старт/продолжить), автосейв при уходе в меню, Esc + гейт | ✅ (2026-09-09). `core/scene.ts` — перенос схемы v1 без привязки к бэкенду: `Scene` {enter/exit/update/render}, стек push/pop/replace, переход {duration} фазами **затухание → смена сцены → проявление** (каждая фаза длится duration — полный переход 2×duration); `FadeOverlay {setAlpha}` инъекцией (демка — DOM-слой `#fade`, тесты — заглушка); **параллельные переходы глотаются** (pending-проверка в begin), **enter новой сцены не ожидается** (void), update тикает текущую сцену и во время перехода, render рисует весь стек (нижние сцены видны). Демка: `gameScene` (tick+рендер) внизу стека, `menuScene` (DOM-панель) сверху — мир живёт под меню с самого старта; Esc/мост `gotoMenu` — из игры автосохранение в слот `auto` + push меню, из меню — возврат; «Продолжить» грузит `auto` (кнопка disabled без сейва); мост sceneInfo/gotoMenu/startGame. Гейт `scenes.mjs` 11 проверок: старт в меню без перехода, startGame — переход идёт (сцена ещё меню), **фейд в конце фазы out — кадр чёрный**, переход завершён (меню скрыто), кадры меню/игры различаются, gotoMenu пишет автосейв, возврат в меню, «Продолжить» вернул героя на место сохранения, параллельный переход глотается, повторный переход работает. 164 юнит-теста. Уроки: **CSS `display:flex` перебивает атрибут `hidden`** — нужен `#menu[hidden]{display:none}` (гейт save поймал: меню «скрыто», но видно на каждом кадре, испортив попиксельное сравнение); **полный переход — это out+in (2×duration = 48 тиков при 0.4 с), а не 24** — в ручном режиме гейты обязаны пройти все 48 тиков, иначе фейд висит частично затемняя каждый кадр (ловится только попиксельным сравнением); **все гейты демки теперь входят в игру через startGame(false)+60 тиков** — демка стартует в меню, старые гейты снимали кадры поверх панели меню. | +| 34 | v2 трек 13: бой — перенос боевой системы v1 на воксельный движок: PlayerCombat (конус «гулкий звон» будит пепел / заряженный резонанс усыпляет), EnemyBrain (стейты dormant/rise/chase/hurt/flee/sleeping, слух по уровням шума, LOS с кэшем), CombatWorld (спавн, снаряды плевуна, трупы, толкучка-сепарация); демка: сгустки-кучки на лугу, атака на Space, HUD сердец, мост combatSpawn/combatClear/combatInfo/combatStats/attackInput + гейт | ✅ (2026-09-09). `combat/` движка: `shapes.ts`/`los.ts`/`stats.ts`/`enemies.ts`/`playerCombat.ts`/`brain.ts` (StateMachine + Cooldown) + `world.ts` — CombatWorld с хуками {onEnemyHurt/onEnemyKilled/onEnemyAwake/onAttack/onSpit} вместо EventBus v1; deps {getPlayerPos, damagePlayer, solid, opaque} — мир инъекцией предикатами. 16 юнит-тестов (217 всего). **Масштаб: 1 боевой юнит = 3 вокселя** — боевые числа v1 в юнитах (герой ≈ 1), конвертация только на границе демки (позиции /3, отброс ×3), CombatWorld живёт в чистом юнит-пространстве. Демка: спавны (патруль (8,10)→(10,12), спящие плевун (19,20) и тяжёлый (22,13)), кучки — приплюснутые эллипсоиды из вокселей (спящий — нижний ряд, «блин»; hurt — вспышка 0.12 с), шум шагов 0.35, HUD `#hp` сердечками (скрывается вне игровой сцены — isBlack-проверка scenes-гейта сканирует весь кадр). Гейт `combat.mjs` 14 проверок: 3 сгустка/2 спят, кучка рисуется (diff ~1100), звон будит без урона (rise), погоня до равновесия 0.55, конус 3→1→труп→исчез, смерть героя 4,3,2,1,5 + респаун (40,60), плевун 5→4 + HUD-зона, резонанс усыпляет обоих. Уроки: **dormant глух к близости героя** — спящий вплотную не просыпается (только шум ≥ 0.7 или урон), гейт будит звоном издали; **равновесие сепарации = сумма радиусов (0.55) > attackRange ползуна (0.4)** — ползун «липнет», но стоящего героя не бьёт (v1-faithful; контактные удары в гейте — телепортом в упор, тяжёлый 0.65 ≤ 0.8 бьёт); **толкучка вталкивает врага в strike-радиус** — два сгустка с разных сторон дают удар без атаки, спавнить с одной стороны; **combatClear обязан чистить и снаряды** — снаряд плевуна летит после удаления врагов и «призраком» наносит урон; **боевые точки проверять прогоном** — у старта героя (40,60) пусто, у (14,84) препятствие (равновесие погони ровно 0.55 — маркер открытости). | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -593,6 +594,8 @@ Inventory v1 1:1, эффекты giveItem/takeItem исполняются игрой), ~~сохранение~~ (трек 11, строка 32: SaveManager v1 1:1, состояние демки восстанавливается попиксельно), ~~сцены/переходы~~ (трек 12, строка 33: -SceneManager — стек + фейд, параллельные переходы глотаются). Остаётся: -производственный порядок следующих треков (бой, аудио-перенос — по мере -движения к воспроизведению демки v1 на v2). +SceneManager — стек + фейд, параллельные переходы глотаются), ~~бой~~ +(трек 13, строка 34: PlayerCombat/EnemyBrain/CombatWorld — перенос v1, +хуки вместо EventBus, 1 боевой юнит = 3 вокселя, конвертация на границе +демки). Остаётся: перенос аудио (по мере движения к воспроизведению +демки v1 на v2). diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 7c048b4..2d90fdf 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -47,6 +47,7 @@ node apps/demo/tools/checks/inventory.mjs # скриншот-гейт инвентаря (/tmp/v2_inv_*.png) node apps/demo/tools/checks/save.mjs # скриншот-гейт сохранений (/tmp/v2_save_*.png) node apps/demo/tools/checks/scenes.mjs # скриншот-гейт сцен/меню (/tmp/v2_scenes_*.png) +node apps/demo/tools/checks/combat.mjs # скриншот-гейт боя (/tmp/v2_combat_*.png) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 ``` @@ -145,6 +146,25 @@ DOM-слой, тесты — заглушка); параллельные переходы глотаются (pending-проверка), enter новой сцены не ожидается (void); update тикает текущую сцену и во время перехода, render рисует весь стек; + - `src/combat/` — боевая система (перенос v1, чистые юниты; **1 боевой + юнит = 3 вокселя** демки, конвертация только на границе демки): + `shapes.ts` (horDist/horNorm/inCircle/inCone), `los.ts` (hasLineOfSight + по opaque-предикату), `stats.ts` (PLAYER_COMBAT: конус range 1.0/урон 2/ + кулдаун 0.45, резонанс заряд 0.6/радиус 3/сон hp≤4, неуязвимость 0.8, + отброс 0.75, wakeRadius 4.5), `enemies.ts` (ENEMY_KINDS: ползун — + контактный лип 0.4, плевун — держит дистанцию 2.25 и плюёт, тяжёлый — + медленный контакт 0.8; withDefaults), `playerCombat.ts` (заряд/выпуск: + короткий — конус, длинный ≥0.6 — резонанс; hp/неуязвимость), + `brain.ts` (EnemyBrain: стейты dormant/rise/chase/hurt/flee/sleeping, + слух — шум 0.35 шаги / 0.6 выстрел слышат бодрые / 1.0 звон будит + спящих в wakeRadius; StateMachine + Cooldown), `world.ts` (CombatWorld: + спавн/шум/урон/снаряды/трупы; deps {getPlayerPos, damagePlayer, solid, + opaque, hooks?} — мир предикатами; хуки {onEnemyHurt, onEnemyKilled, + onEnemyAwake, onAttack, onSpit} вместо EventBus v1; EnemySystem — + senses→intent→шаг→толкучка-сепарация (равновесие = сумма радиусов; + вталкивание в solid-колонку откатывается), ProjectileSystem — полёт, + opaque-колонка или срок жизни убивает; moveCircleHor/circleHitsSolid — + круг против bbox-клеток, ось-раздельный зажим). - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; @@ -242,6 +262,20 @@ соседние фазы различаются, порог 50) + числа через мост (клип замкнут, противофазы ног/рук, контакт стоп в i=0/i=4, приседание и качание корня). Фазы 0 и 4 двухшагового цикла совпадают — это норма, не баг. +- `apps/demo/tools/checks/combat.mjs` — скриншот-гейт боя (14 проверок): + 3 сгустка на лугу (патруль бодр, плевун и тяжёлый спят), кучка спящего + рисуется (дифф ~1100 px), звон будит спящего без урона (враг за краем + конуса, радиус звука 4.5 → rise), погоня до равновесия сепарации 0.55, + конус 3→1→труп→труп исчезает (0.5 с), смерть героя (серия 4,3,2,1,5 — + телепорты в упор; респаун на старте (40,60) с полным hp), плевун 5→4 + (замах+полёт ~2.2 с) + HUD-зона сердец перерисовалась, резонанс (заряд + ≥0.6 с) усыпляет обоих. Мост: `combatSpawn(kind, x, z, patrol?)` (позиции + в ЮНИТАХ; без patrol — dormant), `combatClear()` (враги И снаряды), + `combatInfo()` (сгустки {kind,state,pos,hp,asleep,dead}, снаряды, hp, + заряд), `combatStats()` (enemies/asleep/dead/projectiles), `attackInput(down)` + (Space агентом: press — заряд, release — конус/резонанс). Все боевые + проверки — на чистом ряду z=60 у старта героя (40,60); сцены демки + (спавны) проверяются до первого combatClear. ## Известные грабли (уже собранные) @@ -317,6 +351,29 @@ - **Демка стартует в меню (стек сцен)**: любой новый гейт обязан сначала войти в игру (`startGame(false)` + 60 тиков), иначе stepTicks тикает только меню, а скриншоты снимаются поверх панели меню. +- **Боевой юнит ≠ воксель**: числа v1 — в юнитах (герой ≈ 1 юнит), тело + героя в демке 3 вокселя → **1 юнит = 3 вокселя**; CombatWorld живёт в + чистом юнит-пространстве, конвертация (/3, ×3) — только на границе демки + (getPlayerPos, отброс, кучки). Смешение пространств — урок №1. +- **dormant глух к близости героя**: спящий сгусток вплотную НЕ просыпается + (только шум ≥ 0.7 или урон) — гейту будить звоном издали (за краем конуса, + чтобы не нанести урон: враг на 1.3–2 юнита, конус 1.0, звук 4.5). +- **Равновесие сепарации = сумма радиусов, и оно больше attackRange + ползуна** (0.35+0.2=0.55 > 0.4): ползун «липнет» рядом, но стоящего героя + не бьёт (v1-faithful); контактный урон в гейте — телепортом героя прямо + на врага (вокс-сетка даёт остаток ≤ ~0.24 < 0.4). Тяжёлый (0.65 ≤ 0.8) бьёт. +- **Толкучка вталкивает врага в strike-радиус**: два сгустка с разных + сторон героя выдавливают одного внутрь 0.4 — герой теряет сердце «без + причины»; спавнить проверочных сгустков с одной стороны. +- **combatClear обязан чистить и снаряды**: плевун успевает плюнуть дважды + (кулдаун 2.2 с < 240 тиков гейта), снаряд летит после удаления врагов и + «призраком» наносит урон герою в следующей проверке. +- **Боевые точки выбирать прогоном, не по глазам**: у старта героя (40,60) + чисто (равновесие погони ровно 0.55 — маркер открытости), у (14,84) + стоит препятствие — сгусток в chase топчется на месте. +- **`AgentClient.call` вариадический**: `call('combatSpawn', 'crawler', 15, 20)`, + а не `call('combatSpawn', ['crawler', 15, 20])` — массив уходит ОДНИМ + аргументом (ENEMY_KINDS[Array] → undefined). ## Рабочие привычки diff --git a/v2/apps/demo/index.html b/v2/apps/demo/index.html index 608df55..86858c0 100644 --- a/v2/apps/demo/index.html +++ b/v2/apps/demo/index.html @@ -43,6 +43,20 @@ #inv .title { color: #626c4c; } #inv .item { color: #d99a32; } + /* Сердца героя: правый верхний угол (тёплый — жизнь) */ + #hp { + position: fixed; + top: 12px; + right: 12px; + padding: 8px 12px; + background: rgba(23, 23, 29, 0.85); + color: #d99a32; + font: 18px/1.2 monospace; + border: 1px solid #44454e; + border-radius: 4px; + letter-spacing: 2px; + } + /* Фейд перехода: чёрный слой поверх всего, прозрачность двигает движок */ #fade { position: fixed; @@ -121,6 +135,7 @@
+ diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index 1228aba..2c5f450 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -11,9 +11,10 @@ mannequin, walkClip, Character, GameLoop, screenMoveToWorld, DialogueRunner, validateDialogue, nearestInteractable, Inventory, SaveManager, SceneManager, + PlayerCombat, CombatWorld, moveCircleHor, PLAYER_COMBAT, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueState, DialogueView, Interactable } from '@rpg/engine'; -import type { InventoryData, Scene, FadeOverlay } from '@rpg/engine'; +import type { InventoryData, Scene, FadeOverlay, EnemyKindId, EnemyComp, Entity, Hor } from '@rpg/engine'; import * as THREE from 'three'; // Слоты палитры арт-библии: индексы сцены → hex @@ -143,6 +144,149 @@ const npc = new Character(MAN_RIG, MAN, WALK, [42, 47], { speed: 0 }); renderer.addVoxelCloud(npc.visual()); +// ---- Бой: CombatWorld (в юнитах v1) + сгустки-вьюхи (облака) ---- +// Боевые числа v1 — в юнитах (герой ≈ 1 юнит); в демке тело героя 3 вокселя, +// поэтому 1 боевой юнит = U вокселей. Конвертация — только здесь, на границе. +const U = 3; + +/** Кучка золы: цвет — серые слоты арт-библии (пепел холодный, тёплый — жизнь). */ +const ASH = { dark: '#44454e', mid: '#5b5b66', light: '#94949e', deep: '#343b2c' } as const; +/** Цвет вспышки урона (кадр «получил урон»). */ +const ASH_FLASH = '#b2b2bb'; // P5H — выгоревший свет + +interface ClumpModel { + size: readonly [number, number, number]; + voxels: readonly { x: number; y: number; z: number; hex: string }[]; +} + +/** Генератор «пепельного сгустка»: приплюснутый эллипсоид с рваным краем. */ +function clumpModel(kind: EnemyKindId): ClumpModel { + const [sx, sy, sz] = kind === 'heavy' ? [6, 3, 6] : kind === 'spitter' ? [4, 3, 4] : [4, 2, 4]; + const seed = kind === 'heavy' ? 5 : kind === 'spitter' ? 11 : 3; + const voxels: { x: number; y: number; z: number; hex: string }[] = []; + for (let x = 0; x < sx; x++) + for (let y = 0; y < sy; y++) + for (let z = 0; z < sz; z++) { + const dx = (x + 0.5) / sx - 0.5, dy = (y + 0.5) / sy - 0.5, dz = (z + 0.5) / sz - 0.5; + const bump = ((x * 31 + y * 17 + z * 7 + seed) % 7) * 0.015; + if ((dx * dx + dy * dy * 2.2 + dz * dz) * 4.4 > 1 - bump) continue; + // пасть плевуна: тёмная яма в центре верхушки + const maw = kind === 'spitter' && y === sy - 1 + && Math.abs(x + 0.5 - sx / 2) < 0.8 && Math.abs(z + 0.5 - sz / 2) < 0.8; + const hex = maw ? ASH.deep : y === sy - 1 ? ASH.light : y === 0 ? ASH.dark : ASH.mid; + voxels.push({ x, y, z, hex }); + } + return { size: [sx, sy, sz], voxels }; +} + +/** Колонка юнита (3×3 вокселя) блокирует движение: стены, дом, деревья (цветок проходим). */ +function solidU(cx: number, cz: number): boolean { + for (let vx = cx * U; vx < cx * U + U; vx++) + for (let vz = cz * U; vz < cz * U + U; vz++) + for (let y = 2; y < 8; y++) { + const s = world.get(vx, y, vz); + if (s !== 0 && s !== 9) return true; + } + return false; +} + +const playerCombat = new PlayerCombat(); +const heroPosU = (): Hor => ({ x: hero.body.pos[0] / U, z: hero.body.pos[2] / U }); +/** Игровое время (сек): таймеры вьюх — вспышки урона. */ +let gameT = 0; + +const combat = new CombatWorld({ + getPlayerPos: heroPosU, + /** Урон герою: неуязвимость/кламп — в PlayerCombat; отброс и отклик — здесь. */ + damagePlayer: (n, from) => { + if (playerCombat.takeDamage(n) === 0) return; + const dx = hero.body.pos[0] / U - from.x, dz = hero.body.pos[2] / U - from.z; + const d = Math.hypot(dx, dz) || 1; + const p = heroPosU(); + moveCircleHor(p, { x: (dx / d) * PLAYER_COMBAT.knockback, z: (dz / d) * PLAYER_COMBAT.knockback }, 0.5, solidU); + hero.body.pos[0] = p.x * U; + hero.body.pos[2] = p.z * U; + hero.body.vel = [0, 0, 0]; + renderHp(); + }, + solid: solidU, + opaque: solidU, // высокие колонки блокируют и взгляд (звон не огибает дом) + hooks: { + onEnemyHurt: (e) => { + const v = enemyViews.get(e); + if (v) v.flashUntil = gameT + 0.12; + }, + onEnemyKilled: () => toasts.push('Сгусток развеялся'), + }, +}); + +/** Вьюха сгустка: облако + модель + таймер вспышки урона. */ +interface EnemyView { cloud: VoxelCloud; model: ClumpModel; flashUntil: number } +const enemyViews = new Map(); + +/** Воксели сгустка в мире: центр по XZ на позиции, подушка — спящий/труп. */ +function clumpVoxels(m: ClumpModel, en: EnemyComp, flash: boolean, maxRow: number) { + const wx = en.pos.x * U, wz = en.pos.z * U; + return m.voxels + .filter((v) => v.y <= maxRow) + .map((v) => ({ + pos: [wx + v.x - m.size[0] / 2, 2 + v.y, wz + v.z - m.size[2] / 2] as [number, number, number], + color: flash ? ASH_FLASH : v.hex, + })); +} + +/** Синхронизация вьюх с ECS-миром: спящий/труп — нижний ряд, живой — во весь рост. */ +function syncEnemies(): void { + for (const [e, view] of enemyViews) + if (!combat.enemies.has(e)) { + view.cloud.dispose(); + enemyViews.delete(e); + } + for (const [e, en] of combat.enemies) { + let view = enemyViews.get(e); + if (!view) { + view = { cloud: renderer.addVoxelCloud([]), model: clumpModel(en.kind.id), flashUntil: 0 }; + enemyViews.set(e, view); + } + const maxRow = en.brain.asleep || en.brain.dead ? 0 : view.model.size[1] - 1; + view.cloud.update(clumpVoxels(view.model, en, gameT < view.flashUntil, maxRow)); + } +} + +// Население луга: патрульный ползун, дремлющий плевун и тяжёлый сгусток +combat.spawnEnemy('crawler', { x: 8, z: 10 }, { patrol: { points: [{ x: 8, z: 10 }, { x: 10, z: 12 }], pauseSec: 1.2 } }); +combat.spawnEnemy('spitter', { x: 19, z: 20 }); +combat.spawnEnemy('heavy', { x: 22, z: 13 }); +syncEnemies(); + +/** Отпустить атаку: конус по направлению взгляда или резонанс под ногами. */ +function releaseAttack(): void { + const act = playerCombat.release(); + if (!act) return; + if (act === 'attack') { + combat.playerConeAttack(heroPosU(), { x: Math.sin(hero.yaw), z: Math.cos(hero.yaw) }); + } else { + combat.resonancePulse(heroPosU()); + } +} + +// ---- HUD сердец: ♥ заполненные, ♡ потерянные (тёплый — жизнь) ---- +const hpEl = document.getElementById('hp') as HTMLDivElement; +let lastHp = -1; +function renderHp(): void { + if (playerCombat.hp === lastHp) return; + lastHp = playerCombat.hp; + hpEl.textContent = '♥'.repeat(playerCombat.hp) + '♡'.repeat(PLAYER_COMBAT.maxHp - playerCombat.hp); +} +/** Сердца видны только в игре (фейд/меню — чистый кадр, гейту важен чёрный). */ +function showHp(on: boolean): void { + hpEl.hidden = !on; + if (on) { + lastHp = -1; + renderHp(); + } +} + // ---- Взаимодействие и диалоги: граф — данные, рантайм — движок ---- const hintEl = document.getElementById('hint') as HTMLDivElement; const dialogEl = document.getElementById('dialog') as HTMLDivElement; @@ -334,12 +478,20 @@ e.preventDefault(); interactNearest(); } + // Space: тап — короткий удар, удержание — заряд низкого резонанса + if (e.code === 'Space' && sm.current === gameScene && !runner.active) { + e.preventDefault(); + if (!e.repeat) playerCombat.startCharge(); + } if (e.code === 'Escape') { e.preventDefault(); gotoMenu(); // hoisted-функция (объявлена ниже, в секции сцен) } }); -window.addEventListener('keyup', (e) => heldKeys.delete(e.code)); +window.addEventListener('keyup', (e) => { + heldKeys.delete(e.code); + if (e.code === 'Space' && playerCombat.isCharging() && sm.current === gameScene) releaseAttack(); +}); function keyboardMove(): [number, number] { let x = 0, y = 0; for (const code of heldKeys) { @@ -355,12 +507,28 @@ /** Слежение камеры за героем (мост отключает для фиксированного кадра). */ let followHero = true; -/** Один шаг логики: ввод → герой → облако → камера следит (шаг пиксельной сетки). */ +/** Один шаг логики: ввод → герой → бой → облака → камера (шаг пиксельной сетки). */ function tick(dt: number): void { + gameT += dt; const move = agentInput ?? keyboardMove(); hero.setMove(screenMoveToWorld(move, ISO_YAW)); hero.step(world, dt); heroCloud.update(hero.visual()); + playerCombat.update(dt); + // шаги героя — тихий шум (0.35): бодрые сгустки в hearRadius настораживаются + if (Math.hypot(hero.body.vel[0], hero.body.vel[2]) > 0.1) combat.noise(heroPosU(), 0.35); + combat.update(dt); + syncEnemies(); + if (playerCombat.dead) { + // респаун: полный hp на точке старта (труп героя не рисуем) + toasts.push('Герой пал в пепле… но луга милосердны'); + playerCombat.revive(); + hero.body.pos = [40, 2, 60]; + hero.body.vel = [0, 0, 0]; + heroCloud.update(hero.visual()); + if (followHero) renderer.setTarget([40, 2, 60]); + renderHp(); + } if (followHero) { const b = hero.body.pos; renderer.setTarget([Math.round(b[0]), 2, Math.round(b[2])]); @@ -445,6 +613,7 @@ enter(): void { menuContBtn.disabled = !saves.has('auto'); // «Продолжить» есть только с автосейвом menuEl.hidden = false; + showHp(false); // сердца — только в игре }, exit(): void { menuEl.hidden = true; @@ -455,7 +624,9 @@ const gameScene: Scene = { name: 'game', - enter(): void {}, // мир собран при старте модуля + enter(): void { + showHp(true); + }, exit(): void {}, update(dt: number): void { tick(dt); @@ -683,6 +854,56 @@ startGame(load: boolean): void { menuAction(load); }, + /** Заспавнить сгусток: kind, позиция в юнитах, опц. патруль (точки в юнитах). */ + combatSpawn( + kind: string, + x: number, + z: number, + patrol?: { points: [number, number][]; pauseSec: number }, + ): number { + const e = combat.spawnEnemy(kind as EnemyKindId, { x, z }, patrol + ? { patrol: { points: patrol.points.map(([px, pz]) => ({ x: px, z: pz })), pauseSec: patrol.pauseSec } } + : undefined); + syncEnemies(); + return e; + }, + /** Убрать всех сгустков и снаряды (чистый кадр для проверок). */ + combatClear(): void { + for (const [e] of combat.enemies) combat.world.destroyEntity(e); + combat.enemies.clear(); + for (const [e] of combat.projectiles) combat.world.destroyEntity(e); + combat.projectiles.clear(); + syncEnemies(); + }, + /** Снапшот боя: сгустки, снаряды, герой. */ + combatInfo() { + return { + enemies: [...combat.enemies].map(([e, en]) => ({ + id: e, kind: en.kind.id, state: en.brain.state, + pos: { x: en.pos.x, z: en.pos.z }, hp: en.hp, + asleep: en.brain.asleep, dead: en.brain.dead, + })), + projectiles: combat.projectiles.size, + hp: playerCombat.hp, + maxHp: PLAYER_COMBAT.maxHp, + charging: playerCombat.isCharging(), + chargeTime: playerCombat.chargeTime, + }; + }, + /** Счётчики боя для гейта: всего/спит/трупы/снаряды. */ + combatStats() { + let asleep = 0, dead = 0; + for (const [, en] of combat.enemies) { + if (en.brain.asleep) asleep++; + if (en.brain.dead) dead++; + } + return { enemies: combat.enemies.size, asleep, dead, projectiles: combat.projectiles.size }; + }, + /** Space агентом: true — нажать (заряд), false — отпустить (удар/резонанс). */ + attackInput(down: boolean): void { + if (down) playerCombat.startCharge(); + else if (playerCombat.isCharging()) releaseAttack(); + }, perf(): { chunks: number; meshes: number; buildMs: number; vertices: number; triangles: number } { return { ...chunkStats(), buildMs, ...stats() }; }, diff --git a/v2/apps/demo/tools/checks/combat.mjs b/v2/apps/demo/tools/checks/combat.mjs new file mode 100644 index 0000000..1b71ebf --- /dev/null +++ b/v2/apps/demo/tools/checks/combat.mjs @@ -0,0 +1,221 @@ +/** + * Скриншот-гейт боя v2 (трек «бой»): сгустки на лугу (спящие кучки и + * патруль), пробуждение от гулкого звука и погоня, удар конусом убивает + * (труп исчезает), смерть героя — респаун на старте с полным hp, плевун + * наносит урон герою, резонанс усыпляет. Ручной режим цикла — детерминизм. + * Скриншоты: /tmp/v2_combat_*.png. + * + * Запуск: node apps/demo/tools/checks/combat.mjs [--port 5312] + */ +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { launchBrowser, startDevServer, openGame, parseArgs } from '@rpg/engine/tools/agent-lib.mjs'; +import { decodePng } from '@rpg/engine/tools/png.mjs'; + +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port ?? 5312); +const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url)); + +const server = startDevServer({ port, cwd: DEMO_DIR }); +const report = { name: 'combat', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +/** Чистая поляна у точки старта: герой (40,60) вокс = (13.33,20) юнит, + * ряд по z=60 пуст — проверено прожектором (равновесие погони ровно 0.55). */ +const H = { x: 40, z: 60 }; + +try { + await server.waitReady; + const browser = await launchBrowser(); + try { + const client = await openGame(browser, { url: `http://localhost:${port}/` }); + const errs = client.errors(); + check('нет pageerror при загрузке', errs.length === 0, errs.slice(0, 3)); + + await client.call('setManual', true); + // Демка стартует в меню — входим в игру и проходим фейд (48 тиков) + запас + await client.call('startGame', false); + await client.call('stepTicks', 60); + const si = await client.call('sceneInfo'); + check('вошли в игру', si.current === 'game' && !si.transitioning, si); + + // Луг населён: патрульный ползун бодр, плевун и тяжёлый спят в пепле + const stats0 = await client.call('combatStats'); + check('на лугу 3 сгустка, двое спят', + stats0.enemies === 3 && stats0.asleep === 2 && stats0.dead === 0, stats0); + + // Кучка спящего рисуется: кадр с врагом vs чистый кадр (кучка 4×4 + // мировых вокселя — десятки пикселей апскейла; луг после combatClear + // пуст — дифф только от кучки) + await client.call('combatClear'); + await client.call('heroTeleport', H.x, H.z); + await client.call('stepTicks', 2); + const clearShot = await client.screenshot('/tmp/v2_combat_clear.png'); + await client.call('combatSpawn', 'crawler', 14.5, 20); + await client.call('stepTicks', 2); + const pileShot = await client.screenshot('/tmp/v2_combat_pile.png'); + const pileDiff = countDiff(png(clearShot), png(pileShot)); + check('кучка спящего сгустка рисуется', pileDiff > 100, { pileDiff }); + + // Гулкий удар будит спящего БЕЗ урона: сгусток за краем конуса + // (1.37 > 1.0), но в радиусе звука (4.5) → rise. Лёгкий шаг ставит + // взгляд героя на +x (для конуса ниже); затем погоня — равновесие + // сепарации 0.55 (радиусы 0.35+0.2) + await client.call('combatClear'); + await client.call('heroTeleport', H.x, H.z); + await client.call('stepTicks', 2); + await client.call('combatSpawn', 'crawler', 14.7, 20); + await client.call('setInput', 1, 1); // экранная диагональ = +x мира + await client.call('stepTicks', 2); + await client.call('setInput', null, null); + await client.call('attackInput', true); + await client.call('attackInput', false); + await client.call('stepTicks', 5); + let info = await client.call('combatInfo'); + check('звон будит спящего без урона (rise)', + info.enemies[0].state === 'rise' && info.enemies[0].hp === 3, info.enemies[0]); + await client.call('stepTicks', 150); + info = await client.call('combatInfo'); + const heroNow = await client.call('heroInfo'); + const en = info.enemies[0]; + const dist = Math.hypot(en.pos.x - heroNow.pos[0] / 3, en.pos.z - heroNow.pos[2] / 3); + check('погоня: догнал и «липнет» рядом (0.55)', en.state === 'chase' && dist < 0.75, { state: en.state, dist }); + + // Удар конусом: 3 → 1 → труп; труп исчезает через 0.5 с + await client.call('attackInput', true); + await client.call('attackInput', false); + await client.call('stepTicks', 5); + info = await client.call('combatInfo'); + check('конус ранил: hp 3 → 1', info.enemies[0].hp === 1, info.enemies[0]); + await client.call('stepTicks', 30); // кулдаун удара 0.45 с = 27 тиков + await client.call('attackInput', true); + await client.call('attackInput', false); + await client.call('stepTicks', 5); + info = await client.call('combatInfo'); + check('второй удар добил: труп', info.enemies[0].dead === true, info.enemies[0]); + await client.call('stepTicks', 45); // corpseTimer 0.5 с + const after = await client.call('combatStats'); + check('труп исчез', after.enemies === 0, after); + + // Смерть героя: контактные удары в упор — респаун на старте с полным + // hp. Спящий вплотную НЕ просыпается (dormant глух к близости), поэтому + // сначала будим звоном издали и даём дойти до 0.55; ползун в равновесии + // дальше attackRange (0.4) не бьёт, поэтому каждый круг телепортируем + // героя прямо на врага (вокс-сетка даёт остаток ≤ ~0.24 < 0.4). + // Секция ДО плевуна: у героя ровно 5 сердец. + await client.call('combatClear'); + await client.call('heroTeleport', H.x, H.z); + await client.call('stepTicks', 2); + await client.call('combatSpawn', 'crawler', 14.5, 20); + await client.call('attackInput', true); + await client.call('attackInput', false); + await client.call('stepTicks', 150); + const hpSeries = []; + let respawnPos = null; + let prevHp = 5; + for (let i = 0; i < 5; i++) { + const ci = await client.call('combatInfo'); + const foe = ci.enemies.find((e) => !e.dead && !e.asleep); + if (!foe) { check(`круг ${i + 1}: сгусток жив и бодр`, false, ci.enemies); break; } + await client.call('heroTeleport', Math.round(foe.pos.x * 3), Math.round(foe.pos.z * 3)); + await client.call('stepTicks', 3); + const c = await client.call('combatInfo'); + hpSeries.push(c.hp); + if (respawnPos === null && c.hp > prevHp) { + // hp подскочил — герой пал и возродился; снимаем точку респауна + const h = await client.call('heroInfo'); + respawnPos = h.pos; + } + prevHp = c.hp; + await client.call('stepTicks', 70); // неуязвимость 0.8 с + кулдаун ползуна 0.8 с + } + const hero = await client.call('heroInfo'); + const finalInfo = await client.call('combatInfo'); + const fell = hpSeries.join(',') === '4,3,2,1,5'; // 5-е сердце → смерть и респаун + check('смерть героя: 5 ударов, респаун с полным hp', fell && finalInfo.hp === 5, { hpSeries, finalInfo }); + const rp = respawnPos ?? hero.pos; + check('респаун на точке старта (40,60)', + Math.abs(rp[0] - 40) < 0.01 && Math.abs(rp[2] - 60) < 0.01, rp); + + // Плевун: будим издали, держит дистанцию и плюёт — герой теряет сердце + await client.call('combatClear'); + await client.call('heroTeleport', H.x, H.z); + await client.call('stepTicks', 2); + await client.call('combatSpawn', 'spitter', 15.5, 20); + await client.call('setFollow', false); // камера фиксирована — зона HUD чистая + await client.call('stepTicks', 2); + const hearts5Shot = await client.screenshot('/tmp/v2_combat_hearts5.png'); + await client.call('attackInput', true); // звон будит (конус не достаёт: 2.17 > 1.0) + await client.call('attackInput', false); + await client.call('stepTicks', 240); // замах 0.35 + плевок + полёт ~1 с + info = await client.call('combatInfo'); + check('плевун нанёс урон: hp 5 → 4', info.hp === 4, { hp: info.hp, enemies: info.enemies }); + const hearts4Shot = await client.screenshot('/tmp/v2_combat_hearts4.png'); + const hudDiff = countDiffZone(png(hearts5Shot), png(hearts4Shot), 0, 60, 700, 960); + check('HUD сердец перерисовался (правый верх)', hudDiff > 100, { hudDiff }); + await client.call('setFollow', true); + + // Резонанс: заряд ≥ 0.6 с усыпляет обоих в радиусе 3 (сгустки за краем + // конуса — проснулись от звона без урона; спавн с одной стороны, чтобы + // толкучка не втолкнула кого-то в strike-радиус героя) + await client.call('combatClear'); + await client.call('heroTeleport', H.x, H.z); + await client.call('stepTicks', 2); + await client.call('combatSpawn', 'crawler', 15.0, 20); + await client.call('combatSpawn', 'crawler', 15.3, 20); + await client.call('attackInput', true); + await client.call('attackInput', false); + await client.call('stepTicks', 10); + await client.call('attackInput', true); + await client.call('stepTicks', 40); // заряд 0.83 с ≥ 0.6 + await client.call('attackInput', false); + info = await client.call('combatInfo'); + check('резонанс усыпил обоих (sleeping)', + info.enemies.length === 2 && info.enemies.every((e) => e.state === 'sleeping'), info.enemies); + + await client.call('combatClear'); + + report.ok = report.checks.every((c) => c.ok); + } finally { + await browser.close(); + } +} catch (e) { + check('прогон без исключений', false, String(e)); +} finally { + server.stop(); +} + +console.log(JSON.stringify(report, null, 2)); +process.exit(report.ok ? 0 : 1); + +/** PNG со скриншота. */ +function png(path) { + return decodePng(readFileSync(path)); +} + +/** Число различающихся пикселей двух кадров одного размера. */ +function countDiff(a, b) { + let n = 0; + for (let i = 0; i < Math.min(a.rgba.length, b.rgba.length); i += 4) { + if (Math.abs(a.rgba[i] - b.rgba[i]) > 8 || Math.abs(a.rgba[i + 1] - b.rgba[i + 1]) > 8 + || Math.abs(a.rgba[i + 2] - b.rgba[i + 2]) > 8) n++; + } + return n; +} + +/** Различия зоны [y0..y1)×[x0..x1) — HUD-зона без шума сцены. */ +function countDiffZone(a, b, y0, y1, x0, x1) { + const stride = a.width * 4; + let n = 0; + for (let y = y0; y < y1; y++) { + for (let x = x0; x < x1; x++) { + const i = y * stride + x * 4; + if (Math.abs(a.rgba[i] - b.rgba[i]) > 8 || Math.abs(a.rgba[i + 1] - b.rgba[i + 1]) > 8 + || Math.abs(a.rgba[i + 2] - b.rgba[i + 2]) > 8) n++; + } + } + return n; +} \ No newline at end of file diff --git a/v2/packages/engine/src/combat/__tests__/brain.test.ts b/v2/packages/engine/src/combat/__tests__/brain.test.ts new file mode 100644 index 0000000..6f69ef1 --- /dev/null +++ b/v2/packages/engine/src/combat/__tests__/brain.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from 'vitest'; +import { ENEMY_KINDS, withDefaults } from '../enemies'; +import { EnemyBrain, NOISE_HEAR_LEVEL, type EnemySenses } from '../brain'; + +/** Тик ИИ на 1/60 с; возвращает последнее намерение. */ +const run = (b: EnemyBrain, seconds: number, senses: EnemySenses): ReturnType => { + let intent: ReturnType = { type: 'idle' }; + for (let i = 0; i < Math.round(seconds * 60); i++) intent = b.update(1 / 60, senses); + return intent; +}; + +/** Прогон с ловлей конкретного намерения (strike/shoot выдаются одним тиком). */ +const sawIntent = (b: EnemyBrain, seconds: number, senses: EnemySenses, type: string): boolean => { + for (let i = 0; i < Math.round(seconds * 60); i++) { + if (b.update(1 / 60, senses).type === type) return true; + } + return false; +}; + +const away: EnemySenses = { dist: 3.1, dirToPlayer: { x: 0, z: 1 } }; +const close: EnemySenses = { dist: 0.25, dirToPlayer: { x: 1, z: 0 } }; + +/** Разбудить и дождаться погони (спит → rise 0.6 с → chase). */ +const wakeUp = (b: EnemyBrain, senses: EnemySenses): void => { + b.hearLoud(); + run(b, 0.7, senses); +}; + +describe('EnemyBrain (ползун)', () => { + it('стартует спящим в пепле и не двигается', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + expect(b.asleep).toBe(true); + expect(b.update(1 / 60, close).type).toBe('idle'); + }); + + it('гулкий звук поднимает: rise → chase', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + expect(b.state).toBe('rise'); + run(b, 0.7, away); + expect(b.state).toBe('chase'); + // Вдали от героя — движение к нему + expect(run(b, 0.05, away)).toEqual({ type: 'move', dir: { x: 0, z: 1 } }); + }); + + it('в радиусе атаки бьёт контактом по кулдауну', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + wakeUp(b, away); + expect(sawIntent(b, 0.1, close, 'strike')).toBe(true); + // Кулдаун: в следующее окно удара нет, после — снова есть + expect(sawIntent(b, 0.1, close, 'strike')).toBe(false); + run(b, ENEMY_KINDS.crawler.attackCooldown, away); + expect(sawIntent(b, 0.1, close, 'strike')).toBe(true); + }); + + it('урон станит и возвращает в погоню', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + wakeUp(b, away); + b.hurt(); + expect(b.state).toBe('hurt'); + expect(b.update(1 / 60, away).type).toBe('idle'); + run(b, 0.3, away); + expect(b.state).toBe('chase'); + }); + + it('резонанс усыпляет на заданное время, потом просыпается', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + wakeUp(b, away); + b.putToSleep(4); + expect(b.asleep).toBe(true); + expect(run(b, 2, away).type).toBe('idle'); + expect(b.asleep).toBe(true); + run(b, 2.2, away); + expect(b.state).toBe('rise'); // проснулся — сначала подъём + run(b, 0.7, away); + expect(b.state).toBe('chase'); + expect(b.asleep).toBe(false); + }); + + it('смерть окончательна: повторный die возвращает false', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + expect(b.die()).toBe(true); + expect(b.dead).toBe(true); + expect(b.die()).toBe(false); + // Урон и сон мёртвого не трогают + b.hurt(); + b.putToSleep(4); + expect(b.dead).toBe(true); + }); +}); + +describe('EnemyBrain (тяжёлый)', () => { + it('перед ударом есть замах 0.6 с', () => { + const b = new EnemyBrain(ENEMY_KINDS.heavy); + wakeUp(b, away); + // Сразу — только замах, урона нет + run(b, 0.05, close); + expect(b.state).toBe('windup'); + run(b, 0.3, close); + expect(b.state).toBe('windup'); + // Через 0.6 с замаха — удар и фаза атаки + run(b, 0.35, close); + expect(b.state).toBe('attack'); + expect(sawIntent(b, 0.05, close, 'strike')).toBe(false); // уже прошёл + }); +}); + +describe('EnemyBrain (плевун)', () => { + it('сближение — отходит, издали — идёт ближе', () => { + const b = new EnemyBrain(ENEMY_KINDS.spitter); + wakeUp(b, { dist: 3.1, dirToPlayer: { x: 1, z: 0 } }); + expect(b.state).toBe('chase'); + // Слишком близко к keepDistance — пятится + const back = run(b, 0.05, { dist: 1.3, dirToPlayer: { x: 1, z: 0 } }); + expect(back.type).toBe('move'); + if (back.type === 'move') expect(back.dir.x).toBeLessThan(0); + // Слишком далеко — подходит + const toward = run(b, 0.05, { dist: 3.8, dirToPlayer: { x: 0, z: 1 } }); + expect(toward.type).toBe('move'); + if (toward.type === 'move') expect(toward.dir.z).toBeGreaterThan(0); + }); + + it('на дистанции атаки — замах и плевок', () => { + const b = new EnemyBrain(ENEMY_KINDS.spitter); + wakeUp(b, { dist: 100, dirToPlayer: { x: 1, z: 0 } }); + const mid: EnemySenses = { dist: 2.5, dirToPlayer: { x: 1, z: 0 } }; + // Входим в радиус атаки — замах + run(b, 0.05, mid); + expect(b.state).toBe('windup'); + // Через windup — плевок (ловим намерение в тик срабатывания) + expect(sawIntent(b, 0.45, mid, 'shoot')).toBe(true); + }); +}); + +/** Патрульные точки в юнитах. */ +const points = [ + { x: 10.5, z: 7.5 }, + { x: 12.5, z: 9.5 }, +]; + +/** Сенсоры «герой далеко и не виден» (для патрульных сценариев). */ +const far = (pos: { x: number; z: number }): EnemySenses => ({ + dist: 30, + dirToPlayer: { x: 1, z: 0 }, + pos, + canSee: false, + home: { x: 10.5, z: 7.5 }, + hpFraction: 1, +}); + +describe('EnemyBrain: патруль', () => { + it('с патрулём стартует бодрым в patrol, без — спит в пепле', () => { + const patrolled = new EnemyBrain(withDefaults(ENEMY_KINDS.crawler), { points, pauseSec: 1 }); + expect(patrolled.state).toBe('patrol'); + expect(patrolled.asleep).toBe(false); + const sleeper = new EnemyBrain(ENEMY_KINDS.crawler); + expect(sleeper.state).toBe('dormant'); + expect(sleeper.asleep).toBe(true); + }); + + it('дошёл до точки — пауза, потом идёт к следующей (ping-pong)', () => { + const b = new EnemyBrain(ENEMY_KINDS.heavy, { points, pauseSec: 1 }); + // Стоим на точке 0 — сразу пауза + expect(b.update(1 / 60, far(points[0]!)).type).toBe('idle'); + run(b, 1.1, far(points[0]!)); + // Пауза истекла — движение к точке 1 (вниз-вправо) + const move = run(b, 0.05, far(points[0]!)); + expect(move.type).toBe('move'); + if (move.type === 'move') { + expect(move.dir.x).toBeCloseTo(0.707, 1); + expect(move.dir.z).toBeCloseTo(0.707, 1); + expect(move.run).toBeUndefined(); // патруль шагом, не бегом + } + // Дошли до точки 1 — снова пауза и разворот назад + run(b, 3.2, far(points[1]!)); + const back = run(b, 0.05, far(points[1]!)); + expect(back.type).toBe('move'); + if (back.type === 'move') expect(back.dir.x).toBeLessThan(0); + }); + + it('шум уровня 0.35 вырывает из патруля в настороженность', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); + const pos = { x: 10.5, z: 7.5 }; + b.update(1 / 60, { ...far(pos), noise: { origin: { x: 11.5, z: 8.5 }, level: NOISE_HEAR_LEVEL } }); + expect(b.state).toBe('wary'); + // Идёт к источнику шума + const move = b.update(1 / 60, { ...far(pos), noise: null }); + expect(move.type).toBe('move'); + }); + + it('тихий шум (ниже порога) не настораживает', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); + b.update(1 / 60, { ...far(points[0]!), noise: { origin: { x: 11, z: 8 }, level: 0.2 } }); + expect(b.state).toBe('patrol'); + }); +}); + +describe('EnemyBrain: настороженность', () => { + it('увидел героя в aggroRange — погоня', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); + const pos = { x: 10.5, z: 7.5 }; + b.update(1 / 60, { ...far(pos), noise: { origin: pos, level: 0.5 } }); + expect(b.state).toBe('wary'); + b.update(1 / 60, { dist: 3, dirToPlayer: { x: 1, z: 0 }, pos, canSee: true }); + expect(b.state).toBe('chase'); + }); + + it('герой не показался за warySec — возврат к дому', () => { + const b = new EnemyBrain(ENEMY_KINDS.heavy, { points, pauseSec: 1 }); // warySec 3 + const pos = { x: 10.5, z: 7.5 }; + b.update(1 / 60, { ...far(pos), noise: { origin: { x: 11, z: 8 }, level: 0.5 } }); + run(b, 1, { ...far({ x: 11, z: 8 }), noise: null }); + expect(b.state).toBe('wary'); + run(b, 2.2, { ...far({ x: 11, z: 8 }), noise: null }); + expect(b.state).toBe('return'); + // Идёт к дому и на месте — снова в патруль + run(b, 3, far({ x: 10.7, z: 7.7 })); + expect(b.state).toBe('patrol'); + }); +}); + +describe('EnemyBrain: погоня и отступление', () => { + it('потерял героя из виду дольше deaggroSec — возврат', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, z: 0 } }); + expect(b.state).toBe('chase'); + // Герой скрылся: 2.5 с без LOS — и враг уходит домой (позицию двигаем руками) + const pos = { x: 10, z: 10 }; + for (let i = 0; i < Math.round(2.6 * 60); i++) { + b.update(1 / 60, { dist: 3, dirToPlayer: { x: 1, z: 0 }, canSee: false, pos, home: { x: 5, z: 5 } }); + pos.x -= 0.007; + } + expect(b.state).toBe('return'); + }); + + it('герой дальше 1.5 aggro — не гоняется по всей карте', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, z: 0 } }); + run(b, 0.1, { dist: 7, dirToPlayer: { x: 1, z: 0 } }); // 1.5 * aggro 4 = 6 + expect(b.state).toBe('return'); + }); + + it('мало hp — бежит прочь бегом, оторвался — успокоился', () => { + const b = new EnemyBrain(withDefaults(ENEMY_KINDS.heavy)); // fleeBelowHpFraction 0.35 + const chase: EnemySenses = { dist: 1, dirToPlayer: { x: 1, z: 0 } }; + b.hearLoud(); + run(b, 0.7, chase); + run(b, 0.1, { ...chase, hpFraction: 0.3 }); + expect(b.state).toBe('flee'); + const flee = b.update(1 / 60, { ...chase, hpFraction: 0.3 }); + expect(flee.type).toBe('move'); + if (flee.type === 'move') { + expect(flee.dir.x).toBe(-1); // прочь от героя + expect(flee.run).toBe(true); + } + // Герой отстал на fleeRange (4.5) — отбой в настороженность + run(b, 0.1, { dist: 4.6, dirToPlayer: { x: 1, z: 0 }, hpFraction: 0.3 }); + expect(b.state).toBe('wary'); + }); + + it('возврат без патруля — дошёл до дома и снова спит', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, z: 0 } }); + run(b, 2.6, { dist: 3, dirToPlayer: { x: 1, z: 0 }, canSee: false, home: { x: 5, z: 5 } }); + run(b, 5, { dist: 30, dirToPlayer: { x: 1, z: 0 }, canSee: false, pos: { x: 4.8, z: 4.8 }, home: { x: 5, z: 5 } }); + expect(b.state).toBe('dormant'); + expect(b.asleep).toBe(true); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/combat/__tests__/primitives.test.ts b/v2/packages/engine/src/combat/__tests__/primitives.test.ts new file mode 100644 index 0000000..83d946c --- /dev/null +++ b/v2/packages/engine/src/combat/__tests__/primitives.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { angleBetween, horDist, horNorm, inCircle, inCone } from '../shapes'; +import { hasLineOfSight } from '../los'; +import { PlayerCombat } from '../playerCombat'; +import { PLAYER_COMBAT } from '../stats'; +import { ENEMY_KINDS, withDefaults } from '../enemies'; + +describe('shapes (горизонталь x/z)', () => { + it('дистанция и нормаль в плоскости x/z', () => { + expect(horDist({ x: 0, z: 0 }, { x: 3, z: 4 })).toBe(5); + expect(horNorm({ x: 0, z: 0 }, { x: 3, z: 0 })).toEqual({ x: 1, z: 0 }); + expect(horNorm({ x: 1, z: 1 }, { x: 1, z: 1 })).toEqual({ x: 0, z: 0 }); + }); + + it('круг включает границу', () => { + expect(inCircle({ x: 0, z: 0 }, 2, { x: 2, z: 0 })).toBe(true); + expect(inCircle({ x: 0, z: 0 }, 2, { x: 2.01, z: 0 })).toBe(false); + }); + + it('конус: дальность и полуугол по мировым осям', () => { + const from = { x: 0, z: 0 }; + const dir = { x: 1, z: 0 }; + // В пределах дальности и угла + expect(inCone(from, dir, 3, Math.PI / 4, { x: 2, z: 1 })).toBe(true); + // За дальностью + expect(inCone(from, dir, 3, Math.PI / 4, { x: 3.1, z: 0 })).toBe(false); + // Вне полуугла (90°) + expect(inCone(from, dir, 3, Math.PI / 4, { x: 0, z: 2 })).toBe(false); + // Нулевое направление — круг без проверки угла + expect(inCone(from, { x: 0, z: 0 }, 1, 0.01, { x: 0.9, z: 0.4 })).toBe(true); + }); + + it('угол между векторами', () => { + expect(angleBetween({ x: 1, z: 0 }, { x: 0, z: 1 })).toBeCloseTo(Math.PI / 2); + expect(angleBetween({ x: 1, z: 0 }, { x: 2, z: 0 })).toBeCloseTo(0); + }); +}); + +describe('LOS по сетке', () => { + /** Непрозрачна одна колонка (3, 2). */ + const opaque = (tx: number, tz: number) => tx === 3 && tz === 2; + + it('чистая прямая видна', () => { + expect(hasLineOfSight({ x: 0, z: 0.5 }, { x: 6, z: 0.5 }, opaque)).toBe(true); + }); + + it('колонка на пути перекрывает', () => { + expect(hasLineOfSight({ x: 0, z: 0.5 }, { x: 6, z: 0.5 }, () => true)).toBe(false); + expect(hasLineOfSight({ x: 0, z: 2 }, { x: 6, z: 2 }, opaque)).toBe(false); + }); + + it('короткие отрезки и нулевая длина видимы', () => { + expect(hasLineOfSight({ x: 1, z: 1 }, { x: 1.2, z: 1 }, opaque)).toBe(true); + expect(hasLineOfSight({ x: 1, z: 1 }, { x: 1, z: 1 }, opaque)).toBe(true); + }); + + it('нулевая дистанция видна', () => { + expect(hasLineOfSight({ x: 1, z: 1 }, { x: 1, z: 1 }, () => true)).toBe(true); + }); +}); + +describe('PlayerCombat', () => { + it('короткий удар: кулдаун триггерится, повтор до кулдауна — null', () => { + const p = new PlayerCombat(); + p.startCharge(); + expect(p.release()).toBe('attack'); + p.startCharge(); + expect(p.release()).toBe(null); // кулдаун 0.45 ещё не прошёл + p.update(0.5); + p.startCharge(); + expect(p.release()).toBe('attack'); + }); + + it('удержание 0.6 с — резонанс со своим кулдауном', () => { + const p = new PlayerCombat(); + p.startCharge(); + p.update(0.65); + expect(p.release()).toBe('resonance'); + p.startCharge(); + p.update(0.65); + expect(p.release()).toBe(null); // резонанс перезаряжается 3 с + p.update(3); + p.startCharge(); + p.update(0.65); + expect(p.release()).toBe('resonance'); + }); + + it('урон: неуязвимость после удара, кламп к hp, смерть', () => { + const p = new PlayerCombat(); + expect(p.takeDamage(1)).toBe(1); + expect(p.invuln).toBe(true); + expect(p.takeDamage(1)).toBe(0); // неуязвим + p.update(PLAYER_COMBAT.invulnTime + 0.1); + expect(p.takeDamage(99)).toBe(4); // кламп до остатка hp + expect(p.dead).toBe(true); + p.revive(); + expect(p.hp).toBe(PLAYER_COMBAT.maxHp); + expect(p.dead).toBe(false); + }); +}); + +describe('данные врагов', () => { + it('withDefaults подставляет все поля ИИ', () => { + const k = withDefaults(ENEMY_KINDS.crawler); + for (const f of ['aggroRange', 'deaggroSec', 'fleeBelowHpFraction', 'fleeRange', 'hearRadius', 'warySec'] as const) { + expect(k[f]).toBeDefined(); + } + // Явное значение не перезаписывается + expect(withDefaults(ENEMY_KINDS.spitter).aggroRange).toBe(5); + expect(withDefaults(ENEMY_KINDS.crawler).hearRadius).toBe(3.5); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/combat/__tests__/world.test.ts b/v2/packages/engine/src/combat/__tests__/world.test.ts new file mode 100644 index 0000000..40d6caa --- /dev/null +++ b/v2/packages/engine/src/combat/__tests__/world.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest'; +import { circleHitsSolid, CombatWorld, moveCircleHor, type CombatDeps, type CombatHooks } from '../world'; +import type { Hor } from '../shapes'; +import type { EnemyKindId } from '../enemies'; + +/** События хуков, накопленные за тест (в v1 — подписки на EventBus). */ +interface HookLog { + hurt: number; + killed: EnemyKindId[]; + awake: EnemyKindId[]; + attacks: string[]; + spit: number; +} + +/** + * Фейковые зависимости боевого мира: герой в заданной точке, мир проходим + * и прозрачен (переопределяется в тестах), урон и хуки копятся в журнал. + */ +function makeDeps(playerPos: Hor, over: Partial = {}): { + deps: CombatDeps; + damaged: { n: number; from: Hor }[]; + log: HookLog; +} { + const damaged: { n: number; from: Hor }[] = []; + const log: HookLog = { hurt: 0, killed: [], awake: [], attacks: [], spit: 0 }; + const hooks: CombatHooks = { + onEnemyHurt: () => log.hurt++, + onEnemyKilled: (id) => log.killed.push(id), + onEnemyAwake: (id) => log.awake.push(id), + onAttack: (mode) => log.attacks.push(mode), + onSpit: () => log.spit++, + }; + const deps: CombatDeps = { + getPlayerPos: () => playerPos, + damagePlayer: (n, from) => damaged.push({ n, from: { ...from } }), + solid: () => false, + opaque: () => false, + hooks, + ...over, + }; + return { deps, damaged, log }; +} + +/** Прогон мира n секунд тиками 1/60. */ +const run = (w: CombatWorld, seconds: number): void => { + for (let i = 0; i < Math.round(seconds * 60); i++) w.update(1 / 60); +}; + +describe('движение круга по колонкам', () => { + /** Единственная solid-колонка (2,1). */ + const solid = (cx: number, cz: number) => cx === 2 && cz === 1; + + it('вход в колонку зажимается к её грани (с обеих сторон)', () => { + const pos: Hor = { x: 1.5, z: 1.5 }; + moveCircleHor(pos, { x: 1, z: 0 }, 0.2, solid); + expect(pos.x).toBeCloseTo(1.799, 2); // грань x=2 минус радиус и зазор + expect(circleHitsSolid(pos.x, pos.z, 0.2, solid)).toBe(false); + + const back: Hor = { x: 3.5, z: 1.5 }; + moveCircleHor(back, { x: -1, z: 0 }, 0.2, solid); + expect(back.x).toBeCloseTo(3.201, 2); + }); + + it('скольжение вдоль стены: ось Z свободна при блоке по X', () => { + const pos: Hor = { x: 1.5, z: 1.5 }; + moveCircleHor(pos, { x: 1, z: 0.5 }, 0.2, solid); + expect(pos.x).toBeCloseTo(1.799, 2); + expect(pos.z).toBeCloseTo(2.0, 5); // по Z прошёл свободно + }); + + it('погоня в стену останавливает сгустка у грани', () => { + const player: Hor = { x: 5.5, z: 5.5 }; + const { deps } = makeDeps(player, { solid: (cx, cz) => cx === 4 && cz >= 4 && cz <= 6 }); + const w = new CombatWorld(deps); + const e = w.spawnEnemy('crawler', { x: 2.5, z: 5.5 }); + w.noise(player, 1.0); // разбудить и начать погоню + run(w, 3); + const en = w.enemies.get(e)!; + expect(en.pos.x).toBeLessThan(4); // не прошёл сквозь стену + expect(en.pos.x).toBeGreaterThan(3.5); // но доехал до неё + }); +}); + +describe('CombatWorld: спавн и шум', () => { + it('без патруля спит в пепле, с патрулём сразу бодр', () => { + const { deps } = makeDeps({ x: 20, z: 20 }); + const w = new CombatWorld(deps); + const sleeper = w.enemies.get(w.spawnEnemy('crawler', { x: 10.5, z: 10.5 }))!; + expect(sleeper.brain.asleep).toBe(true); + const patrol = w.enemies.get( + w.spawnEnemy('crawler', { x: 12.5, z: 10.5 }, { patrol: { points: [{ x: 12, z: 10 }, { x: 14, z: 10 }], pauseSec: 1 } }) + )!; + expect(patrol.brain.state).toBe('patrol'); + expect(patrol.brain.asleep).toBe(false); + }); + + it('шаги героя (0.35) не будят спящего, гулкий звон (1.0) будит', () => { + const { deps, log } = makeDeps({ x: 20, z: 20 }); + const w = new CombatWorld(deps); + const e = w.spawnEnemy('crawler', { x: 10.5, z: 10.5 }); + const en = w.enemies.get(e)!; + + w.noise({ x: 10.5, z: 10.5 }, 0.35); // шаги прямо рядом + w.update(1 / 60); + expect(en.brain.asleep).toBe(true); + expect(log.awake).toEqual([]); + + w.noise({ x: 10.5, z: 10.5 }, 1.0); // гулкий удар + expect(en.brain.asleep).toBe(false); + expect(en.brain.state).toBe('rise'); + expect(log.awake).toEqual(['crawler']); + }); + + it('бодрый патрульный слышит тихий шум в hearRadius и настораживается', () => { + const { deps } = makeDeps({ x: 20, z: 20 }); + const w = new CombatWorld(deps); + const e = w.spawnEnemy('heavy', { x: 10.5, z: 10.5 }, { + patrol: { points: [{ x: 10, z: 10 }, { x: 12, z: 12 }], pauseSec: 1 }, + }); + w.noise({ x: 11.5, z: 10.5 }, 0.35); // шаги в 1 юните — в hearRadius 4 + w.update(1 / 60); + expect(w.enemies.get(e)!.brain.state).toBe('wary'); + }); + + it('шум вне радиуса слуха не слышен', () => { + const { deps } = makeDeps({ x: 20, z: 20 }); + const w = new CombatWorld(deps); + const e = w.spawnEnemy('crawler', { x: 10.5, z: 10.5 }, { + patrol: { points: [{ x: 10, z: 10 }, { x: 12, z: 12 }], pauseSec: 1 }, + }); + w.noise({ x: 18.5, z: 10.5 }, 0.5); // hearRadius ползуна 3.5, шум в 8 юнитах + w.update(1 / 60); + expect(w.enemies.get(e)!.brain.state).toBe('patrol'); + }); + + it('инварианты боевого мира пусты на честном ИИ', () => { + const { deps } = makeDeps({ x: 20, z: 20 }); + const w = new CombatWorld(deps); + w.spawnEnemy('crawler', { x: 10.5, z: 10.5 }, { + patrol: { points: [{ x: 10, z: 10 }, { x: 12, z: 12 }], pauseSec: 1 }, + }); + run(w, 10); + expect(w.agentInvariants()).toEqual([]); + }); +}); + +describe('CombatWorld: удар конусом и смерть', () => { + it('конус бьёт видимого врага, второй удар добивает, труп исчезает', () => { + const player: Hor = { x: 1.5, z: 1.5 }; + const { deps, log } = makeDeps(player); + const w = new CombatWorld(deps); + const e = w.spawnEnemy('crawler', { x: 2.2, z: 1.5 }); // dist 0.7 ≤ coneRange 1.0 + const en = w.enemies.get(e)!; + + expect(w.playerConeAttack(player, { x: 1, z: 0 })).toBe(1); + expect(en.hp).toBe(1); // 3 - 2 + expect(log.hurt).toBe(1); + expect(log.attacks).toEqual(['cone']); + expect(en.brain.dead).toBe(false); + + expect(w.playerConeAttack(player, { x: 1, z: 0 })).toBe(1); + expect(en.brain.dead).toBe(true); + expect(log.killed).toEqual(['crawler']); + + run(w, 0.6); // corpseTimer 0.5 истекает + expect(w.enemies.size).toBe(0); + expect(w.world.isAlive(e)).toBe(false); + }); + + it('звон не бьёт врага за непрозрачной колонкой', () => { + const player: Hor = { x: 0.5, z: 0.5 }; + const { deps, log } = makeDeps(player, { opaque: (cx, cz) => cx === 1 && cz === 0 }); + const w = new CombatWorld(deps); + const e = w.spawnEnemy('crawler', { x: 2.5, z: 0.5 }); // в конусе, но колонка (1,0) на линии + expect(w.playerConeAttack(player, { x: 1, z: 0 })).toBe(0); + expect(w.enemies.get(e)!.hp).toBe(3); + expect(log.hurt).toBe(0); + }); +}); + +describe('CombatWorld: резонанс, удар врага, снаряды', () => { + it('резонанс усыпляет в радиусе и только их', () => { + const player: Hor = { x: 0, z: 0 }; + const { deps } = makeDeps(player); + const w = new CombatWorld(deps); + const near = w.enemies.get(w.spawnEnemy('crawler', { x: 2, z: 0 }))!; + const farEnemy = w.enemies.get(w.spawnEnemy('crawler', { x: 3.5, z: 0 }))!; + w.noise({ x: 0, z: 0 }, 1.0); // поднять обоих из пепла + expect(w.resonancePulse(player)).toBe(1); + expect(near.brain.asleep).toBe(true); + expect(farEnemy.brain.asleep).toBe(false); // 3.5 > resonanceRange 3.0 + }); + + it('тяжёлый доходит до радиуса атаки и бьёт контактом', () => { + const player: Hor = { x: 10, z: 10 }; + const { deps, damaged } = makeDeps(player); + const w = new CombatWorld(deps); + w.spawnEnemy('heavy', { x: 10.7, z: 10 }); // dist 0.7 ≤ attackRange 0.8 + w.noise(player, 1.0); + run(w, 1.5); // подъём 0.6 + замах 0.6 + удар + expect(damaged.length).toBe(1); + expect(damaged[0]!.n).toBe(2); + }); + + it('ползун догоняет героя и «липнет» на дистанции тел', () => { + const player: Hor = { x: 10, z: 10 }; + const { deps } = makeDeps(player); + const w = new CombatWorld(deps); + const e = w.spawnEnemy('crawler', { x: 7, z: 10 }); + w.noise(player, 1.0); + run(w, 8); + const en = w.enemies.get(e)!; + const d = Math.hypot(en.pos.x - player.x, en.pos.z - player.z); + expect(d).toBeLessThan(0.6); // радиусы 0.35 + 0.2 + expect(d).toBeGreaterThan(0.4); // тела не слипаются + }); + + it('снаряд летит и попадает в героя', () => { + const player: Hor = { x: 7, z: 5 }; + const { deps, damaged } = makeDeps(player); + const w = new CombatWorld(deps); + w.spawnProjectile({ x: 5, z: 5 }, { x: 1, z: 0 }, 1); + run(w, 1.2); // 2 юнита на скорости 2.2 + expect(damaged.length).toBe(1); + expect(damaged[0]!.n).toBe(1); + expect(w.projectiles.size).toBe(0); + }); + + it('снаряд глохнет о непрозрачную колонку и не доходит до героя', () => { + const player: Hor = { x: 7, z: 5 }; + const { deps, damaged } = makeDeps(player, { opaque: (cx, cz) => cx === 6 && cz === 5 }); + const w = new CombatWorld(deps); + w.spawnProjectile({ x: 5, z: 5 }, { x: 1, z: 0 }, 1); + run(w, 1); + expect(damaged.length).toBe(0); + expect(w.projectiles.size).toBe(0); + }); + + it('снаряд без попадания исчезает по сроку жизни', () => { + const player: Hor = { x: 100, z: 100 }; + const { deps } = makeDeps(player); + const w = new CombatWorld(deps); + w.spawnProjectile({ x: 5, z: 5 }, { x: 1, z: 0 }, 1); + run(w, 0.5); + expect(w.projectiles.size).toBe(1); // ещё летит (жизнь 1.5 с) + run(w, 1.2); + expect(w.projectiles.size).toBe(0); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/combat/brain.ts b/v2/packages/engine/src/combat/brain.ts new file mode 100644 index 0000000..2f5c24b --- /dev/null +++ b/v2/packages/engine/src/combat/brain.ts @@ -0,0 +1,408 @@ +import { StateMachine } from '../core/stateMachine'; +import { Cooldown } from '../core/cooldown'; +import type { EnemyKindDef } from './enemies'; +import type { Hor } from './shapes'; + +/** + * Чистый ИИ «пепельного сгустка» (перенос v1) поверх движкового StateMachine. + * Сенсоры входят (дистанция/направление до героя, видимость, шум, патруль), + * намерения выходят (куда идти, когда бить/плевать). Без рендера — тестируется + * в Vitest. + * + * Состояния: спящий враг (dormant, старт без патруля) → rise → chase → + * windup → attack → recover → chase; патрульный враг: patrol ↔ wary → chase; + * отступление: chase → flee (низкое hp) / → return (потерял героя) → + * patrol | dormant; sleeping (от низкого резонанса), dead. + * + * Шум слышен бодрому врагу от уровня NOISE_HEAR_LEVEL (в радиусе hearRadius — + * фильтрует CombatWorld); спящих будит только уровень >= 0.7 (гулкий удар). + */ +export type EnemyIntent = + | { type: 'idle' } + | { type: 'move'; dir: Hor; run?: boolean } + | { type: 'strike' } + | { type: 'shoot'; dir: Hor }; + +/** Варианты намерений с направлением — для типизации переиспользуемых констант. */ +type MoveIntent = Extract; +type ShootIntent = Extract; + +export interface EnemySenses { + /** Дистанция до героя, юнитов. */ + dist: number; + /** Нормализованное направление к герою. */ + dirToPlayer: Hor; + /** Позиция врага (для хождения по патрульным точкам и к дому). */ + pos?: Hor; + /** Герой в прямой видимости (LOS; по умолчанию true — старые сенсоры). */ + canSee?: boolean; + /** Последний шум в радиусе слуха (уже отфильтрован по hearRadius). */ + noise?: { origin: Hor; level: number } | null; + /** Точки патруля, юниты (пусто — враг без патруля). */ + patrolPoints?: Hor[]; + /** Точка спавна — «дом», к которому враг отступает, юниты. */ + home?: Hor; + /** Доля hp (0..1; по умолчанию 1). */ + hpFraction?: number; +} + +/** Шум от этого уровня слышит бодрый враг (спящих будит CombatWorld от 0.7). */ +export const NOISE_HEAR_LEVEL = 0.35; + +/** Шум от этого уровня будит спящих (гулкий удар; см. CombatWorld.noise). */ +export const NOISE_WAKE_LEVEL = 0.7; + +/** Легальные состояния автомата (инвариант enemy-state-legal). */ +export const ENEMY_STATES = [ + 'dormant', + 'rise', + 'patrol', + 'wary', + 'chase', + 'windup', + 'attack', + 'recover', + 'hurt', + 'flee', + 'return', + 'sleeping', + 'dead', +] as const; + +/** Дальность, на которой считается «дошёл до точки» (юниты). */ +const ARRIVE_DIST = 0.35; + +/** + * Переиспользуемые намерения и вектор направления: update() вызывается на + * каждый тик каждого врага — новые объекты там не создаются. Потребитель + * (CombatWorld) читает intent сразу, между тиками не хранит. + */ +const INTENT_IDLE: EnemyIntent = { type: 'idle' }; +const INTENT_STRIKE: EnemyIntent = { type: 'strike' }; +const INTENT_MOVE: MoveIntent = { type: 'move', dir: { x: 0, z: 0 } }; +const INTENT_MOVE_RUN: MoveIntent = { type: 'move', dir: { x: 0, z: 0 }, run: true }; +const INTENT_SHOOT: ShootIntent = { type: 'shoot', dir: { x: 0, z: 0 } }; +const MOVE_DIR: Hor = { x: 0, z: 0 }; + +export class EnemyBrain { + readonly sm = new StateMachine(); + private attackCd: Cooldown; + private sleepTimer = 0; + private hurtTimer = 0; + /** Секунд без прямой видимости в погоне. */ + private lostTimer = 0; + /** Таймер настороженности и точка, к которой идём проверять. */ + private waryTimer = 0; + private waryTarget: Hor | null = null; + /** Патруль: индекс точки (ping-pong) и пауза на точке, сек. */ + private patrolIndex = 0; + private patrolDir = 1; + private patrolPause = 0; + + constructor( + readonly kind: EnemyKindDef, + private patrol?: { points: Hor[]; pauseSec: number } + ) { + this.attackCd = new Cooldown(kind.attackCooldown); + + for (const s of ENEMY_STATES) this.sm.add(s, {}); + + // Обычные переходы + this.sm.transition('dormant', 'wake', 'rise'); + this.sm.transition('rise', 'done', 'chase'); + // Патруль: увидел — погоня, услышал шум — насторожился. + this.sm.transition('patrol', 'see', 'chase'); + this.sm.transition('patrol', 'noise', 'wary'); + this.sm.transition('wary', 'see', 'chase'); + this.sm.transition('wary', 'lost', 'return'); + this.sm.transition('chase', 'inRange', 'windup'); + this.sm.transition('chase', 'exhausted', 'sleeping'); + this.sm.transition('chase', 'lost', 'return'); + this.sm.transition('chase', 'flee', 'flee'); + this.sm.transition('flee', 'calm', 'wary'); + this.sm.transition('return', 'home', this.patrol ? 'patrol' : 'dormant'); + this.sm.transition('return', 'see', 'chase'); + this.sm.transition('windup', 'strike', 'attack'); + this.sm.transition('attack', 'done', 'recover'); + this.sm.transition('recover', 'done', 'chase'); + this.sm.transition('hurt', 'done', 'chase'); + this.sm.transition('sleeping', 'wake', 'rise'); + + // Из любого состояния: урон и смерть (кроме dead — повторный вход игнорируется) + this.sm.transitionAny('hurt', 'hurt'); + this.sm.transitionAny('die', 'dead'); + + this.sm.change(this.patrol ? 'patrol' : 'dormant'); + } + + /** Гулкий звук рядом — проснуться (только для спящих; уровень фильтрует CombatWorld). */ + hearLoud(): void { + if (this.sm.current === 'dormant' || this.sm.current === 'sleeping') { + this.sm.handleEvent('wake'); + } + } + + /** Низкий резонанс: усыпить на seconds (мёртвых не трогает). */ + putToSleep(seconds: number): void { + if (this.sm.current === 'dead') return; + this.sm.change('sleeping'); + this.sleepTimer = seconds; + } + + /** Получил урон: короткий стан (не выбивает из dead). */ + hurt(): void { + if (this.sm.current === 'dead') return; + this.hurtTimer = 0.25; + this.sm.handleEvent('hurt'); + } + + /** Умер. Возвращает true, если умер именно сейчас. */ + die(): boolean { + if (this.sm.current === 'dead') return false; + this.sm.handleEvent('die'); + return true; + } + + get state(): string | null { + return this.sm.current; + } + + get asleep(): boolean { + return this.sm.current === 'sleeping' || this.sm.current === 'dormant'; + } + + get dead(): boolean { + return this.sm.current === 'dead'; + } + + /** Тик ИИ: сенсоры внутрь, намерение наружу. */ + update(dt: number, senses: EnemySenses): EnemyIntent { + this.sm.update(dt); + this.attackCd.update(dt); + + const canSee = senses.canSee ?? true; + const aggro = this.kind.aggroRange ?? 4; + + // Таймеры поверх автомата + if (this.hurtTimer > 0) { + this.hurtTimer -= dt; + if (this.hurtTimer <= 0) this.sm.handleEvent('done'); + return INTENT_IDLE; + } + if (this.sm.current === 'sleeping') { + this.sleepTimer -= dt; + if (this.sleepTimer <= 0) this.sm.handleEvent('wake'); + return INTENT_IDLE; + } + if (this.sm.current === 'rise') { + // Подъём из пепла: 0.6 с, потом в погоню + if (this.sm.time >= 0.6) this.sm.handleEvent('done'); + return INTENT_IDLE; + } + + switch (this.sm.current) { + case 'patrol': + return this.updatePatrol(dt, senses, canSee, aggro); + case 'wary': + return this.updateWary(dt, senses, canSee, aggro); + case 'chase': + return this.updateChase(dt, senses, canSee, aggro); + case 'flee': + return this.updateFlee(senses); + case 'return': + return this.updateReturn(senses, canSee, aggro); + case 'windup': { + if (this.sm.time >= this.kind.windup) { + this.sm.handleEvent('strike'); + this.attackCd.triggerForce(); + if (this.kind.keepDistance !== undefined) { + INTENT_SHOOT.dir.x = senses.dirToPlayer.x; + INTENT_SHOOT.dir.z = senses.dirToPlayer.z; + return INTENT_SHOOT; + } + return INTENT_STRIKE; + } + return INTENT_IDLE; + } + case 'attack': { + if (this.sm.time >= Math.max(this.kind.recover, 0.3)) { + this.sm.handleEvent('done'); + } + return INTENT_IDLE; + } + case 'recover': { + if (this.sm.time >= this.kind.recover) this.sm.handleEvent('done'); + return INTENT_IDLE; + } + default: + return INTENT_IDLE; + } + } + + /** Патруль: ход по точкам туда-обратно; герой/шум вырывают из круга. */ + private updatePatrol(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; + if (this.checkNoise(senses)) return INTENT_IDLE; + + const points = this.patrol?.points ?? senses.patrolPoints ?? []; + if (points.length === 0) return INTENT_IDLE; + if (this.patrolPause > 0) { + this.patrolPause -= dt; + return INTENT_IDLE; + } + if (!this.dirInto(MOVE_DIR, senses.pos, points[this.patrolIndex]!)) { + // Точка достигнута: пауза и разворот на следующую (ping-pong). + this.patrolPause = this.patrol?.pauseSec ?? 0; + this.advancePatrolIndex(points.length); + return INTENT_IDLE; + } + return this.moveIntentFrom(MOVE_DIR, false); + } + + /** Настороженность: идём к источнику шума, осматриваемся warySec — и домой. */ + private updateWary(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; + if (this.checkNoise(senses)) return INTENT_IDLE; // свежий шум обновляет цель и таймер + + this.waryTimer -= dt; + if (this.waryTimer <= 0) { + this.sm.handleEvent('lost'); + return INTENT_IDLE; + } + if (!this.waryTarget || !this.dirInto(MOVE_DIR, senses.pos, this.waryTarget)) return INTENT_IDLE; + return this.moveIntentFrom(MOVE_DIR, false); + } + + /** Погоня: потерял героя дольше deaggroSec — домой; мало hp — прочь. */ + private updateChase(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + if (canSee) { + this.lostTimer = 0; + } else { + this.lostTimer += dt; + if (this.lostTimer >= (this.kind.deaggroSec ?? 2.5)) { + this.sm.handleEvent('lost'); + return INTENT_IDLE; + } + } + const fleeAt = this.kind.fleeBelowHpFraction ?? 0; + if (fleeAt > 0 && (senses.hpFraction ?? 1) <= fleeAt) { + this.sm.handleEvent('flee'); + return INTENT_IDLE; + } + if (senses.dist > aggro * 1.5) { + // Убежал слишком далеко даже с LOS — не гоняемся по всей карте. + this.sm.handleEvent('lost'); + return INTENT_IDLE; + } + + const inRange = senses.dist <= this.kind.attackRange; + if (inRange && this.kind.keepDistance === undefined && this.attackCd.ready) { + // контактный (ползун): без замаха + if (this.kind.windup === 0) { + this.attackCd.triggerForce(); + return INTENT_STRIKE; + } + this.sm.handleEvent('inRange'); + return INTENT_IDLE; + } + // Плевун держит дистанцию + if (this.kind.keepDistance !== undefined) { + if (senses.dist < this.kind.keepDistance - 0.4) { + return this.moveIntentFrom(negate(MOVE_DIR, senses.dirToPlayer), false); + } + if (senses.dist <= this.kind.attackRange && this.attackCd.ready) { + this.sm.handleEvent('inRange'); + return INTENT_IDLE; + } + if (senses.dist > this.kind.keepDistance + 0.4) { + return this.moveIntentFrom(senses.dirToPlayer, false); + } + return INTENT_IDLE; + } + return this.moveIntentFrom(senses.dirToPlayer, false); + } + + /** Отступление: прочь от героя бегом, пока он не отстал на fleeRange. */ + private updateFlee(senses: EnemySenses): EnemyIntent { + if (senses.dist >= (this.kind.fleeRange ?? 4)) { + // Оторвался: стоит и осматривается warySec, потом домой. + this.waryTarget = null; + this.waryTimer = this.kind.warySec ?? 2.5; + this.sm.handleEvent('calm'); + return INTENT_IDLE; + } + return this.moveIntentFrom(negate(MOVE_DIR, senses.dirToPlayer), true); + } + + /** Возврат к дому; на месте — снова в патруль или обратно в пепел. */ + private updateReturn(senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; + if (this.checkNoise(senses)) return INTENT_IDLE; + if (senses.home) { + if (!this.dirInto(MOVE_DIR, senses.pos, senses.home)) { + this.lostTimer = 0; + this.sm.handleEvent('home'); + } else { + return this.moveIntentFrom(MOVE_DIR, false); + } + } + return INTENT_IDLE; + } + + /** Переход «увидел героя» (LOS + aggroRange); true — событие отправлено. */ + private checkSee(senses: EnemySenses, canSee: boolean, aggro: number): boolean { + if (canSee && senses.dist <= aggro) { + this.lostTimer = 0; + this.sm.handleEvent('see'); + return true; + } + return false; + } + + /** Переход «услышал шум» (уровень >= порога); true — событие отправлено. */ + private checkNoise(senses: EnemySenses): boolean { + const noise = senses.noise; + if (noise && noise.level >= NOISE_HEAR_LEVEL) { + this.waryTarget = { ...noise.origin }; + this.waryTimer = this.kind.warySec ?? 2.5; + this.sm.handleEvent('noise'); + return true; + } + return false; + } + + /** Нормализованное направление к точке в out; false — точка достигнута (ARRIVE_DIST). */ + private dirInto(out: Hor, from: Hor | undefined, target: Hor): boolean { + if (!from) return false; + const dx = target.x - from.x; + const dz = target.z - from.z; + const d = Math.hypot(dx, dz); + if (d < ARRIVE_DIST) return false; + out.x = dx / d; + out.z = dz / d; + return true; + } + + /** move-намерение с копией направления из dir (общий intent.dir мутируется). */ + private moveIntentFrom(dir: Hor, run: boolean): EnemyIntent { + const intent = run ? INTENT_MOVE_RUN : INTENT_MOVE; + intent.dir.x = dir.x; + intent.dir.z = dir.z; + return intent; + } + + private advancePatrolIndex(count: number): void { + if (count < 2) return; + if (this.patrolIndex + this.patrolDir >= count || this.patrolIndex + this.patrolDir < 0) { + this.patrolDir = -this.patrolDir; + } + this.patrolIndex += this.patrolDir; + } +} + +/** Обратный вектор в out (без аллокации — вызывается в тике). */ +function negate(out: Hor, v: Hor): Hor { + out.x = -v.x; + out.z = -v.z; + return out; +} \ No newline at end of file diff --git a/v2/packages/engine/src/combat/enemies.ts b/v2/packages/engine/src/combat/enemies.ts new file mode 100644 index 0000000..9996609 --- /dev/null +++ b/v2/packages/engine/src/combat/enemies.ts @@ -0,0 +1,113 @@ +/** + * Враги демо-боя (перенос данных v1): «пепельные сгустки» — оживший пепел, + * просыпается от гулкого звука. Чистые данные — боевой системой и тестами. + * Вьюхи (воксельные модели) — на игре, поэтому `frames` v1 сюда не входит. + */ + +export type EnemyKindId = 'crawler' | 'spitter' | 'heavy'; + +export interface EnemyKindDef { + id: EnemyKindId; + /** Здоровье. */ + hp: number; + /** Скорость, юнитов/сек. */ + speed: number; + /** Радиус тела для попаданий, юнитов. */ + radius: number; + /** Дистанция начала атаки, юнитов. */ + attackRange: number; + /** Урон атаки. */ + attackDamage: number; + /** Пауза между атаками, сек. */ + attackCooldown: number; + /** Замах до удара, сек (у ползуна 0 — контактный урон). */ + windup: number; + /** Восстановление после удара, сек. */ + recover: number; + /** Плевун держит дистанцию (юнитов) — отходит при сближении. */ + keepDistance?: number; + /** Дроп «пепельной пыли». */ + motes: number; + // --- ИИ (необязательные: withDefaults подставляет разумные значения) --- + + /** Дистанция обнаружения героя (при прямой видимости), юнитов. */ + aggroRange?: number; + /** Секунд без прямой видимости, после которых враг отступает к дому. */ + deaggroSec?: number; + /** Ниже этой доли hp — отступает (0 — не отступает никогда). */ + fleeBelowHpFraction?: number; + /** При отступлении убегает, пока герой ближе этого радиуса, юнитов. */ + fleeRange?: number; + /** Радиус слуха шума (уровень >= 0.35), юнитов. */ + hearRadius?: number; + /** Секунд настороженности без новой информации, после которых враг возвращается. */ + warySec?: number; +} + +/** Патруль отдельного спавна: точки маршрута (проходится туда-обратно) и пауза. */ +export interface EnemyPatrolDef { + points: { x: number; z: number }[]; + pauseSec: number; +} + +/** Дефолты полей ИИ (контент задаёт только отличия). */ +export function withDefaults(kind: EnemyKindDef): EnemyKindDef { + return { + ...kind, + aggroRange: kind.aggroRange ?? 4, + deaggroSec: kind.deaggroSec ?? 2.5, + fleeBelowHpFraction: kind.fleeBelowHpFraction ?? 0, + fleeRange: kind.fleeRange ?? 4, + hearRadius: kind.hearRadius ?? 5, + warySec: kind.warySec ?? 2.5, + }; +} + +export const ENEMY_KINDS: Record = { + crawler: { + id: 'crawler', + hp: 3, + speed: 0.7, + radius: 0.2, + attackRange: 0.4, + attackDamage: 1, + attackCooldown: 0.8, + windup: 0, + recover: 0.3, + motes: 1, + // Сторожит пепел, шатаясь между кустов; слышит шаги вблизи. + hearRadius: 3.5, + }, + spitter: { + id: 'spitter', + hp: 2, + speed: 0.55, + radius: 0.2, + attackRange: 3.0, + attackDamage: 1, + attackCooldown: 2.2, + windup: 0.35, + recover: 0.4, + keepDistance: 2.25, + motes: 1, + // Обстреливает издали и не отступает — стоит насмерть. + aggroRange: 5, + }, + heavy: { + id: 'heavy', + hp: 8, + speed: 0.45, + radius: 0.3, + attackRange: 0.8, + attackDamage: 2, + attackCooldown: 2, + windup: 0.6, + recover: 0.5, + motes: 3, + // Крупный сгусток: глуховат, но с трудом убитый — отступает при малом hp. + hearRadius: 4, + fleeBelowHpFraction: 0.35, + fleeRange: 4.5, + warySec: 3, + }, +}; \ No newline at end of file diff --git a/v2/packages/engine/src/combat/los.ts b/v2/packages/engine/src/combat/los.ts new file mode 100644 index 0000000..ba9a2ce --- /dev/null +++ b/v2/packages/engine/src/combat/los.ts @@ -0,0 +1,31 @@ +/** + * Линия видимости по сетке (перенос v1, чистая математика без рендера). + * Отрезок дискретизируется шагом 0.25 юнита; колонка с непрозрачным + * препятствием (стена дома, дерево) перекрывает обзор. Правила прозрачности + * — на игре (предикат), движок знает только геометрию. + */ + +/** Шаг дискретизации отрезка, юнитов (1 юнит = 1 воксель по горизонтали). */ +const STEP = 0.25; + +/** Виден ли `to` из `from` с учётом непрозрачных колонок (tx, tz). */ +export function hasLineOfSight( + from: { x: number; z: number }, + to: { x: number; z: number }, + isOpaque: (tx: number, tz: number) => boolean +): boolean { + const dx = to.x - from.x; + const dz = to.z - from.z; + const len = Math.hypot(dx, dz); + if (len < 1e-6) return true; + const n = Math.ceil(len / STEP); + const ux = dx / n; + const uz = dz / n; + // Концевые точки не проверяем: сами враг/герой стоят в своих колонках. + for (let i = 1; i < n; i++) { + const x = from.x + ux * i; + const z = from.z + uz * i; + if (isOpaque(Math.floor(x), Math.floor(z))) return false; + } + return true; +} \ No newline at end of file diff --git a/v2/packages/engine/src/combat/playerCombat.ts b/v2/packages/engine/src/combat/playerCombat.ts new file mode 100644 index 0000000..a2d0847 --- /dev/null +++ b/v2/packages/engine/src/combat/playerCombat.ts @@ -0,0 +1,78 @@ +import { Cooldown } from '../core/cooldown'; +import { PLAYER_COMBAT } from './stats'; + +/** + * Боевое состояние героя (перенос v1): HP, неуязвимость, кулдауны, + * заряд резонанса. Чистая логика — тестируется в Vitest. + */ +export class PlayerCombat { + hp: number; + /** Секунды удержания атаки (заряд низкого резонанса). */ + chargeTime = 0; + private invulnTimer = 0; + /** Кулдаун короткого удара. */ + readonly attackCd = new Cooldown(PLAYER_COMBAT.coneCooldown); + /** Кулдаун низкого резонанса. */ + readonly resonanceCd = new Cooldown(PLAYER_COMBAT.resonanceCooldown); + + constructor(hp: number = PLAYER_COMBAT.maxHp) { + this.hp = Math.min(hp, PLAYER_COMBAT.maxHp); + } + + update(dt: number): void { + if (this.invulnTimer > 0) this.invulnTimer = Math.max(0, this.invulnTimer - dt); + this.attackCd.update(dt); + this.resonanceCd.update(dt); + if (this.isCharging()) this.chargeTime += dt; + } + + /** Атака удерживается (Space зажат) — копим заряд резонанса. */ + startCharge(): void { + if (this.chargeTime === 0) this.chargeTime = 0.0001; // старт отсчёта + } + + isCharging(): boolean { + return this.chargeTime > 0; + } + + /** + * Отпустил: если зарядился до резонанса и кулдаун готов — вернуть 'resonance', + * иначе — обычный удар (если готов). Кулдауны триггерятся здесь. + */ + release(): 'resonance' | 'attack' | null { + const charged = this.chargeTime >= PLAYER_COMBAT.resonanceCharge; + this.chargeTime = 0; + if (charged) { + if (this.resonanceCd.trigger()) return 'resonance'; + return null; // резонанс ещё перезаряжается — молча + } + return this.attackCd.trigger() ? 'attack' : null; + } + + /** Получить урон. Возвращает фактически применённый урон (0 — неуязвим). */ + takeDamage(damage: number): number { + if (this.invulnTimer > 0 || this.hp <= 0) return 0; + const applied = Math.min(damage, this.hp); + this.hp -= applied; + this.invulnTimer = PLAYER_COMBAT.invulnTime; + return applied; + } + + /** Неуязвим (мигание вьюхи в этот период). */ + get invuln(): boolean { + return this.invulnTimer > 0; + } + + get dead(): boolean { + return this.hp <= 0; + } + + /** Восстановление после смерти (респаун). */ + revive(): void { + this.hp = PLAYER_COMBAT.maxHp; + this.invulnTimer = PLAYER_COMBAT.invulnTime; + this.chargeTime = 0; + this.attackCd.reset(); + this.resonanceCd.reset(); + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/combat/shapes.ts b/v2/packages/engine/src/combat/shapes.ts new file mode 100644 index 0000000..6d677a4 --- /dev/null +++ b/v2/packages/engine/src/combat/shapes.ts @@ -0,0 +1,54 @@ +/** + * Простейшие проверки фигур боя (перенос v1): круг и конус удара. + * v2-отличие: конус меряется прямо в мировых горизонтальных осях (x/z) — + * воксели кубические, анизотропной изо-проекции v1 нет (урок №1: всё в + * мировом пространстве, проекция — только на границе рендера). + */ + +/** Горизонтальный вектор/точка мира (бой живёт в плоскости x/z). */ +export interface Hor { + x: number; + z: number; +} + +/** Дистанция между точками в горизонтальной плоскости. */ +export function horDist(a: Hor, b: Hor): number { + return Math.hypot(b.x - a.x, b.z - a.z); +} + +/** Нормализованный вектор из a в b (нулевая дистанция — (0,0)). */ +export function horNorm(a: Hor, b: Hor): Hor { + const dx = b.x - a.x; + const dz = b.z - a.z; + const d = Math.hypot(dx, dz); + return d < 1e-9 ? { x: 0, z: 0 } : { x: dx / d, z: dz / d }; +} + +/** Точка внутри круга (включая границу). */ +export function inCircle(center: Hor, radius: number, point: Hor): boolean { + const dx = point.x - center.x; + const dz = point.z - center.z; + return dx * dx + dz * dz <= radius * radius; +} + +/** Угол между векторами a и b (радианы, 0..PI). */ +export function angleBetween(a: Hor, b: Hor): number { + const la = Math.hypot(a.x, a.z); + const lb = Math.hypot(b.x, b.z); + if (la === 0 || lb === 0) return 0; + let cos = (a.x * b.x + a.z * b.z) / (la * lb); + cos = Math.min(1, Math.max(-1, cos)); + return Math.acos(cos); +} + +/** + * Точка внутри конуса: в пределах range от from и угол до dir не больше halfAngle. + * Нулевое направление — конус «схлопывается» в круг по range (угол не проверяется). + */ +export function inCone(from: Hor, dir: Hor, range: number, halfAngle: number, point: Hor): boolean { + const dx = point.x - from.x; + const dz = point.z - from.z; + if (dx * dx + dz * dz > range * range) return false; + if (dir.x === 0 && dir.z === 0) return true; + return angleBetween(dir, { x: dx, z: dz }) <= halfAngle; +} \ No newline at end of file diff --git a/v2/packages/engine/src/combat/stats.ts b/v2/packages/engine/src/combat/stats.ts new file mode 100644 index 0000000..b4cd239 --- /dev/null +++ b/v2/packages/engine/src/combat/stats.ts @@ -0,0 +1,30 @@ +/** + * Числовые константы боя героя (перенос v1) — чистые данные без рендера. + * Позиции, дистанции и скорости — в мировых юнитах (в v2 1 юнит = 1 воксель + * по горизонтали). Значения те же, что в v1-срезе: удар — «гулкий звон» + * (будит пепел), резонанс — «низкий звон» (усыпляет). + */ + +export const PLAYER_COMBAT = { + /** Здоровье героя (сердечки). */ + maxHp: 5, + /** Радиус тела героя (юниты). */ + radius: 0.35, + /** Конус удара: дальность, полуугол (рад), урон, кулдаун. */ + coneRange: 1.0, + coneHalfAngle: (55 * Math.PI) / 180, + coneDamage: 2, + coneCooldown: 0.45, + /** В зоне удара стоим ближе, чем coneRange (гистерезис авто-подхода). */ + attackStop: 0.85, + /** Низкий резонанс: сколько держать заряд, радиус, сон врага, кулдаун. */ + resonanceCharge: 0.6, + resonanceRange: 3.0, + resonanceSleep: 4, + resonanceCooldown: 3, + /** Неуязвимость после урона, сек; отброс, юнитов. */ + invulnTime: 0.8, + knockback: 0.75, + /** Гулкий удар излучает звук: будит сгустков в этом радиусе. */ + wakeRadius: 4.5, +} as const; \ No newline at end of file diff --git a/v2/packages/engine/src/combat/world.ts b/v2/packages/engine/src/combat/world.ts new file mode 100644 index 0000000..2d0a576 --- /dev/null +++ b/v2/packages/engine/src/combat/world.ts @@ -0,0 +1,406 @@ +import { EntityWorld, type Entity } from '../core/ecs'; +import { hasLineOfSight } from './los'; +import { horDist, horNorm, inCircle, inCone, type Hor } from './shapes'; +import { ENEMY_KINDS, withDefaults, type EnemyKindDef, type EnemyKindId, type EnemyPatrolDef } from './enemies'; +import { EnemyBrain, ENEMY_STATES, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL, type EnemyIntent } from './brain'; +import { PLAYER_COMBAT } from './stats'; + +/** + * Боевой мир (перенос v1, адаптация к вокселям): сущности (враги, снаряды) + * в движковом ECS, поведение — в системах; вьюхи держатся отдельно. + * + * Отличия от v1: бой живёт в горизонтальной плоскости x/z; вместо + * тайловой карты — предикаты колонок мира: `solid` (блокирует движение) + * и `opaque` (блокирует взгляд и снаряды); вместо EventBus — хуки + * `hooks` (вьюхи/звук подписываются игрой). + */ + +export interface EnemyComp { + kind: EnemyKindDef; + brain: EnemyBrain; + pos: Hor; + hp: number; + /** Точка спавна — «дом» для возврата после погони. */ + home: Hor; + /** Точки патруля, юниты (пусто — враг спит в пепле). */ + patrolPoints: Hor[]; + /** Секунды до исчезновения трупа (после смерти). */ + corpseTimer: number; + /** Кэш прямой видимости героя: true/false и сек до пересчёта. */ + los: boolean; + losTimer: number; +} + +export interface ProjectileComp { + pos: Hor; + vel: Hor; + damage: number; + life: number; +} + +/** Хуки боевого мира: реакции вьюх/звука (в v1 — события EventBus). */ +export interface CombatHooks { + /** Сгусток получил урон (died — добит). */ + onEnemyHurt?(entity: Entity, died: boolean): void; + /** Сгусток умер (дроп пыли, вспышка). */ + onEnemyKilled?(kindId: EnemyKindId, pos: Hor): void; + /** Спящий сгусток разбужен гулким звуком. */ + onEnemyAwake?(kindId: EnemyKindId, pos: Hor): void; + /** Атака героя (конус или резонанс) — вьюха сектора/волны. */ + onAttack?(mode: 'cone' | 'resonance', origin: Hor): void; + /** Плевок сгустка (звук; спящих не будит). */ + onSpit?(pos: Hor): void; +} + +export interface CombatDeps { + /** Позиция героя (ноги), юниты. */ + getPlayerPos: () => Hor; + /** Герой получил урон (сцена отбросит/вспыхнет/проиграет звук). */ + damagePlayer: (damage: number, from: Hor) => void; + /** Колонка (cx, cz) блокирует движение (стены, дом). */ + solid: (cx: number, cz: number) => boolean; + /** Колонка блокирует взгляд и снаряды (высокие объекты). */ + opaque: (cx: number, cz: number) => boolean; + /** Хуки реакций (опция). */ + hooks?: CombatHooks; + /** Границы мира для инварианта «враг в пределах» (опция). */ + bounds?: { width: number; height: number }; +} + +/** Окно слышимости шума: событие живёт столько секунд для бодрых ушей. */ +const NOISE_WINDOW = 0.5; +/** Пересчёт LOS раз в 3 тика (60 Гц) — видимость меняется медленнее. */ +const LOS_INTERVAL = 0.05; +/** Бег (отступление) быстрее шага. */ +const RUN_MULT = 1.3; +/** Зазор от грани колонки при зажиме круга. */ +const EPS = 0.001; +/** Радиус попадания снаряда в героя, юнитов. */ +const PLAYER_HIT_RADIUS = 0.2; +/** Скорость плевка и срок жизни, юнитов/с и сек. */ +const PROJECTILE_SPEED = 2.2; +const PROJECTILE_LIFE = 1.5; + +// ---------- движение круга по колонкам ---------- + +/** Пересекает ли круг хоть одну solid-колонку (граница-касание разрешена). */ +export function circleHitsSolid(x: number, z: number, r: number, solid: (cx: number, cz: number) => boolean): boolean { + const minX = Math.floor(x - r); + const maxX = Math.floor(x + r); + const minZ = Math.floor(z - r); + const maxZ = Math.floor(z + r); + for (let cx = minX; cx <= maxX; cx++) { + for (let cz = minZ; cz <= maxZ; cz++) { + if (!solid(cx, cz)) continue; + // Ближайшая к центру круга точка ячейки + const px = Math.max(cx, Math.min(x, cx + 1)); + const pz = Math.max(cz, Math.min(z, cz + 1)); + const dx = x - px; + const dz = z - pz; + if (dx * dx + dz * dz < r * r) return true; + } + } + return false; +} + +/** + * Движение круга с ось-раздельным скольжением вдоль стен (X → Z): застряв + * в solid-колонку, зажимаем к её грани — по аналогии с физикой тел v2. + */ +export function moveCircleHor(pos: Hor, delta: Hor, r: number, solid: (cx: number, cz: number) => boolean): void { + if (delta.x !== 0) { + pos.x += delta.x; + if (circleHitsSolid(pos.x, pos.z, r, solid)) { + pos.x = delta.x > 0 ? Math.floor(pos.x + r) - r - EPS : Math.floor(pos.x - r) + 1 + r + EPS; + } + } + if (delta.z !== 0) { + pos.z += delta.z; + if (circleHitsSolid(pos.x, pos.z, r, solid)) { + pos.z = delta.z > 0 ? Math.floor(pos.z + r) - r - EPS : Math.floor(pos.z - r) + 1 + r + EPS; + } + } +} + +/** Вытолкнуть self из пересечения с чужим кругом (двигается только self). */ +function separate(self: Hor, selfR: number, other: Hor, otherR: number): void { + const dx = self.x - other.x; + const dz = self.z - other.z; + const d = Math.hypot(dx, dz) || 1e-4; + const overlap = selfR + otherR - d; + if (overlap > 0) { + self.x += (dx / d) * overlap; + self.z += (dz / d) * overlap; + } +} + +// ---------- системы ECS ---------- + +/** ИИ и движение сгустков. */ +class EnemySystem { + constructor(private combat: CombatWorld) {} + + update(_world: EntityWorld, dt: number): void { + const combat = this.combat; + const playerPos = combat.deps.getPlayerPos(); + for (const [e, en] of combat.enemies) { + const world = combat.world; + if (en.brain.dead) { + en.corpseTimer -= dt; + if (en.corpseTimer <= 0) { + world.destroyEntity(e); + combat.enemies.delete(e); + } + continue; + } + + // LOS с кэшем: пересчёт раз в LOS_INTERVAL. + en.losTimer -= dt; + if (en.losTimer <= 0) { + en.los = hasLineOfSight(en.pos, playerPos, combat.deps.opaque); + en.losTimer = LOS_INTERVAL; + } + + const dist = horDist(en.pos, playerPos) || 1e-4; + const dirToPlayer = horNorm(en.pos, playerPos); + const noise = combat.noiseFor(en); + const intent: EnemyIntent = en.brain.update(dt, { + dist, + dirToPlayer, + pos: en.pos, + canSee: en.los, + noise, + patrolPoints: en.patrolPoints, + home: en.home, + hpFraction: en.hp / en.kind.hp, + }); + + // Позиция до шага и толкучки: если сепарация втолкнет тело в колонку, + // откатываемся сюда. + const beforeCrowd: Hor = { ...en.pos }; + + switch (intent.type) { + case 'move': { + const speed = en.kind.speed * (intent.run ? RUN_MULT : 1); + moveCircleHor(en.pos, { x: intent.dir.x * speed * dt, z: intent.dir.z * speed * dt }, en.kind.radius, combat.deps.solid); + break; + } + case 'strike': + // Удар доходит, если герой в радиусе тела + замах + if (dist <= en.kind.attackRange + 0.25) { + combat.deps.damagePlayer(en.kind.attackDamage, { ...en.pos }); + } + break; + case 'shoot': + combat.spawnProjectile(en.pos, intent.dir, en.kind.attackDamage); + combat.deps.hooks?.onSpit?.({ ...en.pos }); + combat.noise(en.pos, 0.6); // слышно, но спящих не будит + break; + default: + break; + } + + // Тела: каждый враг выталкивает себя из героя и других врагов + // (сепарация после шага — толкучка не отменяет намерение ИИ). + separate(en.pos, en.kind.radius, playerPos, PLAYER_COMBAT.radius); + for (const [o, other] of combat.enemies) { + if (o === e || other.brain.dead) continue; + separate(en.pos, en.kind.radius, other.pos, other.kind.radius); + } + // Стена сильнее толкучки: втолкнули в solid-колонку — откат. + if (circleHitsSolid(en.pos.x, en.pos.z, en.kind.radius, combat.deps.solid)) { + en.pos.x = beforeCrowd.x; + en.pos.z = beforeCrowd.z; + } + } + } +} + +/** Снаряды: полёт, попадание в героя, срок жизни. */ +class ProjectileSystem { + constructor(private combat: CombatWorld) {} + + update(_world: EntityWorld, dt: number): void { + const combat = this.combat; + const playerPos = combat.deps.getPlayerPos(); + for (const [e, pr] of combat.projectiles) { + pr.life -= dt; + pr.pos.x += pr.vel.x * dt; + pr.pos.z += pr.vel.z * dt; + + // Столкновение с героем + if (inCircle(playerPos, PLAYER_HIT_RADIUS, pr.pos)) { + combat.deps.damagePlayer(pr.damage, { ...pr.pos }); + combat.killProjectile(e); + continue; + } + // Срок истёк или плевок в стену/дом (правила прозрачности — игра). + if (pr.life <= 0 || combat.deps.opaque(Math.floor(pr.pos.x), Math.floor(pr.pos.z))) { + combat.killProjectile(e); + } + } + } +} + +// ---------- фасад ---------- + +export class CombatWorld { + readonly world = new EntityWorld(); + readonly enemies = new Map(); + readonly projectiles = new Map(); + /** Последний шум мира (шаги, удары, плевки); живёт NOISE_WINDOW секунд. */ + private lastNoise: { origin: Hor; level: number; age: number } | null = null; + /** Переиспользуемый view шума для noiseFor — без копии origin на врага/тик. */ + private readonly noiseView: { origin: Hor; level: number } = { origin: { x: 0, z: 0 }, level: 0 }; + + constructor(readonly deps: CombatDeps) { + this.world.addSystem(new EnemySystem(this)); + this.world.addSystem(new ProjectileSystem(this)); + } + + /** + * Заспавнить сгусток. Без patrol спит в пепле (dormant), пока не разбудит + * гулкий звук; с патрулём сразу бродит по маршруту. + */ + spawnEnemy(kindId: EnemyKindId, pos: Hor, opts?: { patrol?: EnemyPatrolDef }): Entity { + const kind = withDefaults(ENEMY_KINDS[kindId]); + const points = opts?.patrol ? opts.patrol.points.map((p) => ({ ...p })) : undefined; + const e = this.world.createEntity(); + this.enemies.set(e, { + kind, + brain: new EnemyBrain(kind, points ? { points, pauseSec: opts!.patrol!.pauseSec } : undefined), + pos: { ...pos }, + hp: kind.hp, + home: { ...pos }, + patrolPoints: points ?? [], + corpseTimer: 0.5, + los: false, + losTimer: 0, + }); + return e; + } + + spawnProjectile(from: Hor, dir: Hor, damage: number): Entity { + const e = this.world.createEntity(); + this.projectiles.set(e, { + pos: { x: from.x + dir.x * 0.3, z: from.z + dir.z * 0.3 }, + vel: { x: dir.x * PROJECTILE_SPEED, z: dir.z * PROJECTILE_SPEED }, + damage, + life: PROJECTILE_LIFE, + }); + return e; + } + + killProjectile(e: Entity): void { + if (!this.world.isAlive(e)) return; + this.world.destroyEntity(e); + this.projectiles.delete(e); + } + + /** + * Короткий удар конусом. Возвращает число поражённых сгустков. + * Побочный эффект: гулкий звон (уровень 1.0) будит спящих в wakeRadius. + */ + playerConeAttack(from: Hor, dir: Hor): number { + this.noise(from, 1.0); + this.deps.hooks?.onAttack?.('cone', { ...from }); + + let hits = 0; + for (const [e, en] of this.enemies) { + if (en.brain.dead) continue; + if (!inCone(from, dir, PLAYER_COMBAT.coneRange, PLAYER_COMBAT.coneHalfAngle, en.pos)) continue; + // Звон не огибает дома: цель должна быть в прямой видимости. + if (!hasLineOfSight(from, en.pos, this.deps.opaque)) continue; + this.damageEnemy(e, en, PLAYER_COMBAT.coneDamage, from); + hits++; + } + return hits; + } + + /** Низкий резонанс: усыпляет сгустков в радиусе. Возвращает число усыпленных. */ + resonancePulse(from: Hor): number { + this.deps.hooks?.onAttack?.('resonance', { ...from }); + + let slept = 0; + for (const [, en] of this.enemies) { + if (en.brain.dead) continue; + if (inCircle(from, PLAYER_COMBAT.resonanceRange, en.pos)) { + en.brain.putToSleep(PLAYER_COMBAT.resonanceSleep); + slept++; + } + } + return slept; + } + + /** + * Шум в точке мира. level 0.35 — слышат бодрые в hearRadius (патруль + * идёт проверять); 0.7+ — гулкий удар, будит и спящих в wakeRadius; + * 0.6 — плевок/смерть сгустка (слышат, но не просыпаются). + */ + noise(pos: Hor, level: number): void { + this.lastNoise = { origin: { ...pos }, level, age: 0 }; + if (level >= NOISE_WAKE_LEVEL) { + for (const [, en] of this.enemies) { + if (inCircle(pos, PLAYER_COMBAT.wakeRadius, en.pos) && en.brain.asleep) { + en.brain.hearLoud(); + this.deps.hooks?.onEnemyAwake?.(en.kind.id, { ...en.pos }); + } + } + } + } + + /** Шум, слышимый конкретному врагу (фильтр по уровню, радиусу слуха и окну). */ + noiseFor(en: EnemyComp): { origin: Hor; level: number } | null { + const n = this.lastNoise; + if (!n || n.age > NOISE_WINDOW || n.level < NOISE_HEAR_LEVEL) return null; + if (en.brain.asleep) return null; // спящего будит только noise() от 0.7 + if (horDist(en.pos, n.origin) > (en.kind.hearRadius ?? 5)) return null; + // Общий view (потребитель копирует origin перед хранением — см. EnemyBrain). + this.noiseView.origin = n.origin; + this.noiseView.level = n.level; + return this.noiseView; + } + + /** Урон сгустку; смерть — дроп, хук и приглушённый шум. */ + damageEnemy(e: Entity, en: EnemyComp, damage: number, from: Hor): void { + en.hp -= damage; + en.brain.hurt(); + this.deps.hooks?.onEnemyHurt?.(e, en.hp <= 0); + if (en.hp <= 0) { + if (en.brain.die()) { + this.noise(en.pos, 0.6); + this.deps.hooks?.onEnemyKilled?.(en.kind.id, { ...en.pos }); + // Отброс трупа от героя — лёгкий визуальный эффект на игре. + en.pos.x += Math.sign(en.pos.x - from.x) * 0.0625; + en.pos.z += Math.sign(en.pos.z - from.z) * 0.0625; + } + } + } + + /** Инварианты боевого мира: легальность состояний и позиций врагов. */ + agentInvariants(): { id: string; severity: 'error'; message: string }[] { + const out: { id: string; severity: 'error'; message: string }[] = []; + for (const [, en] of this.enemies) { + if (!ENEMY_STATES.includes(en.brain.state as (typeof ENEMY_STATES)[number])) { + out.push({ id: 'enemy-state-legal', severity: 'error', message: `${en.kind.id}: неизвестное состояние "${en.brain.state}"` }); + } + if (this.deps.bounds) { + const { width, height } = this.deps.bounds; + const tx = Math.floor(en.pos.x); + const tz = Math.floor(en.pos.z); + if (tx < 0 || tz < 0 || tx >= width || tz >= height) { + out.push({ id: 'enemy-in-bounds', severity: 'error', message: `${en.kind.id} вне мира (${en.pos.x.toFixed(2)},${en.pos.z.toFixed(2)})` }); + } + } + } + return out; + } + + update(dt: number): void { + if (this.lastNoise) { + this.lastNoise.age += dt; + if (this.lastNoise.age > NOISE_WINDOW) this.lastNoise = null; + } + this.world.update(dt); + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/core/__tests__/cooldown.test.ts b/v2/packages/engine/src/core/__tests__/cooldown.test.ts new file mode 100644 index 0000000..303aa53 --- /dev/null +++ b/v2/packages/engine/src/core/__tests__/cooldown.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { Cooldown } from '../cooldown'; +import { StateMachine } from '../stateMachine'; + +describe('Cooldown', () => { + it('trigger готов — отсчёт идёт и завершается', () => { + const cd = new Cooldown(0.5); + expect(cd.ready).toBe(true); + expect(cd.trigger()).toBe(true); + expect(cd.ready).toBe(false); + expect(cd.progress).toBe(1); + cd.update(0.3); + expect(cd.timeRemaining).toBeCloseTo(0.2); + cd.update(0.3); // перелив клампится в 0 + expect(cd.ready).toBe(true); + expect(cd.timeRemaining).toBe(0); + }); + + it('trigger во время отсчёта — отказ, triggerForce перебивает', () => { + const cd = new Cooldown(1); + cd.trigger(); + cd.update(0.5); + expect(cd.trigger()).toBe(false); + cd.triggerForce(); + expect(cd.timeRemaining).toBe(1); + cd.reset(); + expect(cd.ready).toBe(true); + }); +}); + +describe('StateMachine', () => { + it('change: exit старого, enter нового, повторный вход игнорируется', () => { + const sm = new StateMachine(); + const log: string[] = []; + sm.add('a', { enter: () => log.push('a-enter'), exit: () => log.push('a-exit') }); + sm.add('b', { enter: () => log.push('b-enter'), exit: () => log.push('b-exit') }); + sm.change('a'); + sm.change('a'); // повторный вход — ничего + sm.change('b'); + expect(log).toEqual(['a-enter', 'a-exit', 'b-enter']); + expect(sm.current).toBe('b'); + }); + + it('переход по событию, time копится, update дёргает состояние', () => { + const sm = new StateMachine(); + let ticks = 0; + sm.add('idle', { update: () => ticks++ }); + sm.add('run', {}); + sm.transition('idle', 'go', 'run'); + sm.change('idle'); + sm.update(0.1); + sm.update(0.1); + expect(ticks).toBe(2); + expect(sm.time).toBeCloseTo(0.2); + sm.handleEvent('go'); + expect(sm.current).toBe('run'); + expect(sm.time).toBe(0); + }); + + it('transitionAny срабатывает из любого состояния, обычный приоритетнее', () => { + const sm = new StateMachine(); + sm.add('a', {}); + sm.add('b', {}); + sm.add('dead', {}); + sm.transition('a', 'hit', 'b'); + sm.transitionAny('die', 'dead'); + sm.change('a'); + sm.handleEvent('die'); + expect(sm.current).toBe('dead'); + sm.change('a'); + sm.handleEvent('hit'); // обычный переход выигрывает (но цель та же ветка) + expect(sm.current).toBe('b'); + }); + + it('неизвестное событие — игнор (или колбэк), неизвестное состояние — бросок', () => { + const seen: string[] = []; + const sm = new StateMachine((s, e) => seen.push(`${s}:${e}`)); + sm.add('a', {}); + sm.change('a'); + sm.handleEvent('noop'); + expect(sm.current).toBe('a'); + expect(seen).toEqual(['a:noop']); + expect(() => sm.transition('a', 'x', 'nope')).toThrow(); + expect(() => sm.change('nope')).toThrow(); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/core/cooldown.ts b/v2/packages/engine/src/core/cooldown.ts new file mode 100644 index 0000000..3b2298a --- /dev/null +++ b/v2/packages/engine/src/core/cooldown.ts @@ -0,0 +1,45 @@ +/** + * Кулдаун на фиксированном шаге (перенос v1): триггер → отсчёт → готовность. + * Чистая логика без бэкенда — тестируется в Vitest. + */ +export class Cooldown { + private remaining = 0; + + constructor(private readonly seconds: number) {} + + /** Запустить отсчёт заново (только если готов — для перебива см. triggerForce). */ + trigger(): boolean { + if (!this.ready) return false; + this.remaining = this.seconds; + return true; + } + + /** Перезапустить отсчёт даже если не готов. */ + triggerForce(): void { + this.remaining = this.seconds; + } + + update(dt: number): void { + if (this.remaining > 0) { + this.remaining = Math.max(0, this.remaining - dt); + } + } + + get ready(): boolean { + return this.remaining <= 0; + } + + /** Секунд до готовности (0 — уже готов). */ + get timeRemaining(): number { + return this.remaining; + } + + /** 1 — только запущен, 0 — готов. */ + get progress(): number { + return this.seconds > 0 ? this.remaining / this.seconds : 0; + } + + reset(): void { + this.remaining = 0; + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/core/stateMachine.ts b/v2/packages/engine/src/core/stateMachine.ts new file mode 100644 index 0000000..8f6c05d --- /dev/null +++ b/v2/packages/engine/src/core/stateMachine.ts @@ -0,0 +1,96 @@ +/** + * Конечный автомат (перенос v1): состояния с enter/exit/update и переходы + * по событиям. Используется для ИИ врагов, состояний игрока и экранов. + */ +export interface StateDef { + /** Вызывается при входе в состояние. */ + enter?: () => void; + exit?: () => void; + /** dt в секундах, фиксированный шаг. */ + update?: (dt: number) => void; +} + +export class StateMachine { + private states = new Map(); + /** (состояние, событие) -> состояние-цель. */ + private transitions = new Map(); + /** Переходы «из любого состояния» (кроме self-loop): событие -> цель. */ + private anyTransitions = new Map(); + private currentName: string | null = null; + private timeInState = 0; + + constructor( + /** Неизвестное событие в текущем состоянии (по умолчанию — просто игнор). */ + private onUnhandled?: (state: string, event: string) => void + ) {} + + add(name: string, def: StateDef): this { + this.states.set(name, def); + return this; + } + + /** Переход из состояния `from` по событию `event` в состояние `to`. */ + transition(from: string, event: string, to: string): this { + if (!this.states.has(to)) { + throw new Error(`Переход в неизвестное состояние: ${to}`); + } + this.transitions.set(`${from}::${event}`, to); + return this; + } + + /** Перейти в состояние (exit текущего → enter нового). Повторный вход игнорируется. */ + change(name: string): void { + if (!this.states.has(name)) { + throw new Error(`Неизвестное состояние: ${name}`); + } + if (this.currentName === name) return; + if (this.currentName) this.states.get(this.currentName)!.exit?.(); + this.currentName = name; + this.timeInState = 0; + this.states.get(name)!.enter?.(); + } + + /** + * Переход по событию из ЛЮБОГО состояния (кроме повторного входа в цель). + * Удобно для hurt/die в бою: не регистрировать переход из каждого состояния. + * Обычные переходы имеют приоритет над transitionAny. + */ + transitionAny(event: string, to: string): this { + if (!this.states.has(to)) { + throw new Error(`Переход в неизвестное состояние: ${to}`); + } + this.anyTransitions.set(event, to); + return this; + } + + /** Обработать событие: обычный переход текущего состояния, затем «из любого». */ + handleEvent(event: string): void { + if (!this.currentName) return; + const to = this.transitions.get(`${this.currentName}::${event}`) ?? this.anyTransitions.get(event); + if (to) { + this.change(to); + } else { + this.onUnhandled?.(this.currentName, event); + } + } + + get current(): string | null { + return this.currentName; + } + + /** Секунд с момента входа в текущее состояние. */ + get time(): number { + return this.timeInState; + } + + update(dt: number): void { + if (!this.currentName) return; + this.timeInState += dt; + this.states.get(this.currentName)!.update?.(dt); + } + + reset(): void { + this.currentName = null; + this.timeInState = 0; + } +} \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index c328723..87d3326 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -49,6 +49,9 @@ export type { CharacterOptions, CharacterVoxel } from './physics/character'; export { GameLoop } from './core/loop'; export type { LoopCallbacks, LoopOptions } from './core/loop'; +export { Cooldown } from './core/cooldown'; +export { StateMachine } from './core/stateMachine'; +export type { StateDef } from './core/stateMachine'; export { EntityWorld } from './core/ecs'; export type { Entity, EcsSystem } from './core/ecs'; export { screenMoveToWorld } from './core/controls'; @@ -68,4 +71,15 @@ export { Inventory, inventoryFromData } from './inventory/inventory'; export type { InventoryOptions, InventoryChange, InventoryChangeKind, InventoryData } from './inventory/inventory'; export { SaveManager } from './save/saveManager'; -export type { SaveSlotMeta, StorageLike } from './save/saveManager'; \ No newline at end of file +export type { SaveSlotMeta, StorageLike } from './save/saveManager'; +export { horDist, horNorm, inCircle, inCone, angleBetween } from './combat/shapes'; +export type { Hor } from './combat/shapes'; +export { hasLineOfSight } from './combat/los'; +export { PLAYER_COMBAT } from './combat/stats'; +export { ENEMY_KINDS, withDefaults } from './combat/enemies'; +export type { EnemyKindDef, EnemyKindId, EnemyPatrolDef } from './combat/enemies'; +export { EnemyBrain, ENEMY_STATES, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL } from './combat/brain'; +export type { EnemyIntent, EnemySenses } from './combat/brain'; +export { PlayerCombat } from './combat/playerCombat'; +export { CombatWorld, circleHitsSolid, moveCircleHor } from './combat/world'; +export type { CombatDeps, CombatHooks, EnemyComp, ProjectileComp } from './combat/world'; \ No newline at end of file