diff --git a/docs/plan.md b/docs/plan.md index 8a95ea4..672e50b 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -50,6 +50,7 @@ | 28 | v2 трек 7: физика/коллизии воксельного мира — AABB-тело (pos = центр низа, vel, size, grounded) против вокселей (занятый воксель = твёрдый), гравитация, ось-раздельное разрешение X→Z→Y с зажимом к граням вокселей, сабстепы против туннелирования; юнит-тесты (падение/остановка, стена, угол, сход с уступа, потолок, тонкая стена); демка: тело-манекен в сцене с walk-анимацией в движении, мост bodySpawn/bodyStep/bodyInfo + гейт (гравитация, стена, кадр меняется) | ✅ (2026-09-09). `physics/body.ts`: `SolidWorld` (структурный интерфейс — точечная выборка `get`, чистая математика без рендера), `makeBody`/`stepBody` (полунеявный Эйлер: скорость до позиции), `collides` (AABB по воксельным диапазонам, касание грани снаружи — не столкновение), ось-раздельные подшаги X→Z→Y с зажимом к грани вокселя и EPS-зазором (пол — ровно на грань, без ловли своей же опоры), сабстепы (смещение за подшаг < 0.9 вокселя) против туннелирования сквозь тонкие стены. 10 юнит-тестов: падение на грань, покой, сход с уступа, стена останавливает, туннелирование (50 вокс/с при dt=0.1), угол двух стен, потолок, число гравитации. Демка: тело-манекен (AABB 3×10×3 — узкий силуэт; махи ног визуально выходят за AABB, это норма) с walk-анимацией и поворотом по направлению движения; мост `bodySpawn(x,z,vx,vz)` (спавн в воздухе y=6)/`bodyStep(n)` (фиксированный dt=1/60 — детерминизм гейта)/`bodyInfo`/`bodyRemove`. Гейт `physics.mjs` 7 проверок: спавн в воздухе, падение на землю y=2, фаза walk-цикла растёт в движении, стена дома останавливает по грани (pos.x ≈ 44 − w/2 − eps), кадр изменился, манекен рисуется (без тела кадр возвращается). 101 юнит-тест. Уроки: **аргумент `VoxelWorld(n)` — это ВЫСОТА чанка, а не число чанков** (`set` вне y-границ молча возвращает false — тест-мир «потолка» не получил и тест падал загадочно); **`Vec3` в mat.ts — readonly**: мутирующее состояние физики (pos/vel) — обычные кортежи `[number,number,number]`, Vec3 только на входах; **спавн тестового тела проверять на пересечение AABB с миром** — тело 3×10×3 у плиты потолка цепляло её край и «мгновенно приземлялось» на y=6. | | 29 | v2 трек 8: каркас ядра + герой — GameLoop (фиксированный шаг 60 Гц, ручной режим для моста/гейтов), минимальный ECS (решение открытого вопроса «каркас ядра»), ввод как данные (экранные оси, проекция в мир на границе), контроллер персонажа (тело физики + walk от скорости), камера следит; демка: герой на WASD и виртуальным стиком агента, мост setInput/heroInfo/heroTeleport/stepTicks/setManual/setFollow + гейт | ✅ (2026-09-09). `core/loop.ts` (GameLoop: аккумулятор, maxStepsPerFrame, `setManual`+`stepTick` — детерминизм гейтов; now/schedule инъектируются для Node-тестов), `core/ecs.ts` (`EntityWorld` — перенос проверенной минимальной ECS v1: сущность-число, компоненты по имени, query, системы), `core/controls.ts` (ControlState + `screenMoveToWorld` — экранные оси → мировая горизонталь по yaw камеры, диагональ не быстрее), `physics/character.ts` (Character: тело + фаза клипа ∝ путь/stride — стопы не скользят, покой замирает, поворот по движению; `spawnY` — спавн в воздухе для гейта физики; мост тела физики переехал на Character — один путь, урок №4). Рендер: `setTarget` (камера+солнце за целью) + `currentTarget`. Демка: герой на WASD/стрелках (клавиатура демки, `screenMoveToWorld` с ISO_YAW), камера следит с шагом пиксельной сетки (round), мост `setInput`/`heroInfo`/`heroTeleport`/`stepTicks`/`setManual`/`setFollow` (гейт физики держит камеру фиксированной). Гейт `hero.mjs` 9 проверок: герой на земле, ввод агента двигает (+x мира из (1,1) экранного), фаза ровно 1.25 цикла/с (скорость/stride), стена по грани (42.4999), покой замирает, кадр меняется, камера следует, телепорт уезжает. 115 юнит-тестов. Уроки: **фаза = путь/stride, не время** — при скорости 4 и stride 3.2 это 1.25 цикла/с (совпало с длительностью клипа 0.8 с намеренно: stride = speed·duration); **виртуальный стик агента живёт между вызовами моста** — перед чистой проверкой кадра отпускать setInput(null,null); **cancelAnimationFrame нет в Node** — typeof-guard в GameLoop.stop (юнит-тесты с инъекцией schedule). | | 30 | v2 трек 9: диалоги как данные + интерактивные точки — граф диалога (JSON/TS: узлы с репликами, выборами, условиями и эффектами), рантайм отделён от отрисовки, реестр точек взаимодействия; демка: NPC у дома, «E» открывает диалог, HTML-оверлей, 2 графа с эффектами + гейт | ✅ (2026-09-09). `dialogue/graph.ts` — перенос типов v1 (узел/выбор/условия/эффекты), `evalConditions` (когда/когдаНе/переменные/предметы — все группы AND), `validateDialogue` (битые ссылки, тупики, выборы без текста — числовая валидация для агента); состояние — интерфейс `DialogueState` (развязка от GameState v1), мир — `DialogueWorld` (сумка). `dialogue/runner.ts` — рантайм семантики v1: **узел с текстом ждёт игрока** (advance → next, pick → вариант; advance во время выбора игнорируется), **узел без текста — «действие»: применяет эффекты и проваливается в next сам**, MAX_STEPS против циклов; `onEffect` — эффекты `do[]` движок только эмитит, исполнение — игра; геттеры nodeId/node/choices/path/result — снапшоты для моста. `interaction/interactables.ts` — реестр точек {id, pos, radius, label, dialogueId} + чистые `nearestInteractable`/`interactablesInRadius` (Y не учитывается). Демка: страж у дома (Character на скорости 0 — поза покоя, один путь с героем), табличка; клавиша E + мост; HTML-оверлей (панель + кнопки вариантов); 2 графа: страж (выборы → setFlags/setVars, условный узел ask2 по переменной, тост) и табличка (один узел + тост); мост interact/interactInfo/dialogueOpen/dialogueInfo/dialoguePick/dialogueAdvance. Гейт `dialogue.mjs` 15 проверок: графы валидны, подсказка появляется/пропадает по дистанции, подсказка и панель рисуются (сравнение ЗОН кадра — верх/низ, одна позиция камеры), открытие по «E», advance при выборе игнорируется, эффекты флагов/переменных/тостов, путь и результат, закрытие возвращает кадр. 136 юнит-тестов. Уроки: **HUD-элементы обновляются только из тика — любое изменение состояния вне тика (мост, клик кнопки) обязано само дёргать обновление подсказки** (гейт поймал: диалог открыт, подсказка «E» осталась висеть); **скриншот-сравнение HUD делай по зонам кадра с одной позиции камеры** — сравнение целых кадров с разных позиций даёт diff всего луга (324000 px) и проверяет не HUD, а камеру. | +| 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 синхронен, изменения из моста/кнопок применяются сразу. | Отложено: **IsoLayout как параметр игры** — `DEFAULT_ISO` в `math/iso.ts` остаётся единственной точкой входа; прокидывание конфига игры через ~15 мест движка не даёт @@ -586,6 +587,7 @@ GameLoop v1-схемы с ручным режимом, ввод как данные; ~~физика/коллизии в воксельном мире~~ (трек 7), ~~тайлинг мира (чанки)~~ (трек 3), ~~диалоги как данные~~ (трек 9, строка 30: граф + рантайм семантики v1 + -валидатор + точки взаимодействия). Остаётся: производственный порядок -следующих треков (инвентарь/сохранение/сцены/аудио-перенос — по мере -движения к воспроизведению демки v1 на v2). +валидатор + точки взаимодействия), ~~инвентарь~~ (трек 10, строка 31: +Inventory v1 1:1, эффекты giveItem/takeItem исполняются игрой). Остаётся: +производственный порядок следующих треков (сохранение/сцены/аудио-перенос +— по мере движения к воспроизведению демки v1 на v2). diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index 08e1906..a5c8850 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -44,6 +44,7 @@ node apps/demo/tools/checks/physics.mjs # скриншот-гейт физики (/tmp/v2_physics_*.png) node apps/demo/tools/checks/hero.mjs # скриншот-гейт героя (/tmp/v2_hero_*.png) node apps/demo/tools/checks/dialogue.mjs # скриншот-гейт диалогов (/tmp/v2_dialog_*.png) +node apps/demo/tools/checks/inventory.mjs # скриншот-гейт инвентаря (/tmp/v2_inv_*.png) npx vite --port 5299 # демка (в apps/demo), http://localhost:5299 ``` @@ -126,6 +127,11 @@ `Interactable` {id, pos, radius, label, dialogueId?}, чистые `nearestInteractable` (ближайшая в радиусе, Y не учитывается) и `interactablesInRadius` (список с дистанциями); + - `src/inventory/inventory.ts` — `Inventory` (перенос v1 1:1): контейнер + предметов-счётчиков (Map по id), лимиты слотов/стака обрезают и + возвращают факт (модель не бросает), `onChange` с деталями изменения, + serialize/load (мусор игнорируется, кламп под лимиты), + `inventoryFromData`; - `tools/agent-lib.mjs` — браузерный каркас проверок (launchBrowser с опциональным viewport/ startDevServer/openGame/AgentClient/Checks), swiftshader в headless; @@ -144,7 +150,8 @@ /`setInput(x,z|null)` (виртуальный стик агента, экранные оси; null — клавиатура)/`heroInfo()` (pos/vel/grounded/phase/yaw/camTarget)/`heroTeleport(x,z)`/`stepTicks(n)` (ручные шаги цикла)/`setManual(on)`/`setFollow(on)` (камера за героем; off — центр сцены) /`interact()` («E» агентом)/`interactInfo()` (near/label/hint) - /`dialogueOpen(id)`/`dialogueInfo()` (active/nodeId/text/choices/path/flags/vars/toasts/result/graphErrors)/`dialoguePick(i)`/`dialogueAdvance(i)`. + /`dialogueOpen(id)`/`dialogueInfo()` (active/nodeId/text/choices/path/flags/vars/toasts/result/graphErrors)/`dialoguePick(i)`/`dialogueAdvance(i)` + /`invInfo()` (all/size/maxSlots/empty). Герой демки: WASD/стрелки → `screenMoveToWorld` с ISO_YAW, Character (скорость 4, stride 3.2), камера следит с шагом пиксельной сетки. NPC: страж у дома (42,47) — Character на скорости 0 (поза покоя, один @@ -152,8 +159,12 @@ (id/pos/radius/label/dialogueId), подсказка «E — …» в `#hint` (top), диалог — панель `#dialog` (низ, кнопки вариантов). Диалоговые графы — `GRAPHS` (страж: выборы → setFlags/setVars, условный узел ask2 по - переменной asked, тост-эффекты; табличка: один узел + тост), валидируются - `validateDialogue` при старте (`graphErrors` в мосту). + переменной asked, giveItem «цветок», выбор «Отдать цветок» по hasItem → + takeItem; табличка: один узел + тост), валидируются `validateDialogue` + при старте (`graphErrors` в мосту). Сумка героя — `Inventory` + (maxSlots 8), `DialogueWorld.hasItem` смотрит в неё, эффекты + giveItem/takeItem исполняются в `onEffect`, HUD `#inv` перерисовывается + по `inv.onChange`. - `apps/demo/tools/checks/physics.mjs` — скриншот-гейт физики (7 проверок): спавн в воздухе, падение на землю y=2 ровно на грань, фаза walk-цикла растёт в движении, стена дома останавливает по грани @@ -173,6 +184,11 @@ admitted, тост), путь/результат, повторный заход через dialogueOpen, переменная asked открывает условный узел ask2, второй граф таблички, закрытие возвращает кадр. +- `apps/demo/tools/checks/inventory.mjs` — скриншот-гейт инвентаря (8 + проверок): сумка пуста на старте, выбор «Отдать цветок» скрыт без + предмета и виден с ним (hasItem через DialogueWorld), giveItem диалога + наполнил сумку (цветок ×1), HUD сумки рисуется в углу (зона кадра), + takeItem забрал (флаг flowerGivenAway), HUD скрылся (diff 0). - `apps/demo/turntable.html` + `src/turntable.ts` — turntable-лист: все варианты генераторов (деревья/валуны) × 4 ракурса на подставках 8×8×2; мост `sheetInfo()` (геометрия листа для расчёта ячеек — viewSize как у камеры!) diff --git a/v2/apps/demo/index.html b/v2/apps/demo/index.html index d0a2385..0814aad 100644 --- a/v2/apps/demo/index.html +++ b/v2/apps/demo/index.html @@ -27,6 +27,22 @@ border-radius: 4px; white-space: nowrap; } + /* Сумка: список предметов в левом верхнем углу */ + #inv { + position: fixed; + top: 12px; + left: 12px; + padding: 8px 12px; + background: rgba(23, 23, 29, 0.85); + color: #c9c9d2; + font: 15px/1.5 monospace; + border: 1px solid #44454e; + border-radius: 4px; + white-space: pre; + } + #inv .title { color: #626c4c; } + #inv .item { color: #d99a32; } + /* Диалог: панель внизу экрана, реплика + варианты (кнопки) */ #dialog { position: fixed; @@ -61,6 +77,7 @@ + diff --git a/v2/apps/demo/src/main.ts b/v2/apps/demo/src/main.ts index cb03e48..72f4b94 100644 --- a/v2/apps/demo/src/main.ts +++ b/v2/apps/demo/src/main.ts @@ -9,7 +9,7 @@ VoxelWorld, VoxelRenderer, meshChunk, CHUNK_SX, ISO_YAW, treeModel, boulderModel, stampModel, parseVox, remapSlots, type Variant, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, - DialogueRunner, validateDialogue, nearestInteractable, + DialogueRunner, validateDialogue, nearestInteractable, Inventory, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueState, DialogueView, Interactable } from '@rpg/engine'; import * as THREE from 'three'; @@ -157,6 +157,8 @@ { text: 'Да, я копал', next: 'dig', setFlags: ['admitted'] }, { text: 'Кто ты такой?', next: 'who', setVars: { asked: 1 } }, { text: 'Просто мимо иду', next: 'meh' }, + // виден только с цветком в сумке (DialogueWorld.hasItem) + { text: 'Отдать цветок', next: 'away', hasItem: ['flower'] }, ], }, who: { @@ -176,10 +178,17 @@ dig: { speaker: 'Страж', text: 'Держи цветок. Пепел не любит пустых рук.', - do: [{ kind: 'toast', text: 'Получено: цветок пепла' }], + do: [{ kind: 'giveItem', id: 'flower', count: 1 }], setFlags: ['flowerGiven'], next: 'bye', }, + away: { + speaker: 'Страж', + text: 'Страж берёт цветок и кивает. Пепел доволен.', + do: [{ kind: 'takeItem', id: 'flower', count: 1 }], + setFlags: ['flowerGivenAway'], + next: 'bye', + }, meh: { speaker: 'Страж', text: 'Проходи. Но я всё вижу.', setFlags: ['suspicious'], next: 'bye' }, bye: { speaker: 'Страж', text: 'Счастливо.', end: true }, }, @@ -213,6 +222,11 @@ getVar: (k) => vars.get(k), setVar: (k, v) => vars.set(k, v), }; +/** Сумка героя; DialogueWorld предикатов диалогов смотрит в неё. */ +const inv = new Inventory({ maxSlots: 8 }); +const invWorld = { hasItem: (id: string) => inv.has(id) }; +/** Отображаемые имена предметов (HUD). */ +const ITEM_NAMES: Record = { flower: 'Цветок пепла' }; const toasts: string[] = []; /** View поверх DOM-панели: реплика + кнопки вариантов. */ @@ -247,12 +261,37 @@ }; } -const runner = new DialogueRunner(dlgState, makeDialogueView()); +const runner = new DialogueRunner(dlgState, makeDialogueView(), invWorld); runner.onEffect = (op) => { if (op.kind === 'toast' && op.text) toasts.push(op.text); - if (op.kind === 'giveItem' && op.id) toasts.push(`Получено: ${op.id}`); + // give/take — исполнение игрой: движок только сообщает (урок: эффекты как данные) + if (op.kind === 'giveItem' && op.id) inv.add(op.id, op.count ?? 1); + if (op.kind === 'takeItem' && op.id) inv.remove(op.id, op.count ?? 1); }; +// ---- HUD сумки: перерисовка по событию контейнера (вне тика — надёжно) ---- +const invEl = document.getElementById('inv') as HTMLDivElement; +function renderInv(): void { + if (inv.empty) { + invEl.hidden = true; + return; + } + invEl.replaceChildren(); + const title = document.createElement('div'); + title.className = 'title'; + title.textContent = 'Сумка'; + invEl.append(title); + for (const slot of inv.all) { + const el = document.createElement('div'); + el.className = 'item'; + el.textContent = `${ITEM_NAMES[slot.id] ?? slot.id} ×${slot.count}`; + invEl.append(el); + } + invEl.hidden = false; +} +inv.onChange(renderInv); +renderInv(); + /** Ближайшая доступная точка (считается в тике, читается «E»/мостом). */ let currentNear: Interactable | null = null; let hintText: string | null = null; @@ -480,6 +519,10 @@ if (!runner.waitingForChoice) runner.advance(); updateHint(); }, + /** Снапшот сумки: слоты и лимиты. */ + invInfo() { + return { all: inv.all, size: inv.size, maxSlots: inv.slotLimit, empty: inv.empty }; + }, perf(): { chunks: number; meshes: number; buildMs: number; vertices: number; triangles: number } { return { ...chunkStats(), buildMs, ...stats() }; }, diff --git a/v2/apps/demo/tools/checks/inventory.mjs b/v2/apps/demo/tools/checks/inventory.mjs new file mode 100644 index 0000000..b42863f --- /dev/null +++ b/v2/apps/demo/tools/checks/inventory.mjs @@ -0,0 +1,118 @@ +/** + * Скриншот-гейт инвентаря v2 (трек «инвентарь»): эффекты диалогов + * giveItem/takeItem наполняют сумку героя, HUD сумки рисуется в левом + * верхнем углу, выбор «Отдать цветок» виден только с предметом + * (DialogueWorld.hasItem). Ручной режим цикла — детерминизм. + * Скриншоты: /tmp/v2_inv_*.png. + * + * Запуск: node apps/demo/tools/checks/inventory.mjs [--port 5306] + */ +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { sleep, 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 ?? 5306); +const DEMO_DIR = fileURLToPath(new URL('../../', import.meta.url)); + +const server = startDevServer({ port, cwd: DEMO_DIR }); +const report = { name: 'inventory', ok: false, checks: [] }; +const check = (title, ok, info = null) => { + report.checks.push({ title, ok, info }); + console.error(`${ok ? '✓' : '✗'} ${title}${info ? ' — ' + JSON.stringify(info) : ''}`); +}; + +// Страж у дома (42,47); подходим с юга, камера одна на все кадры HUD. +const GUARD = { x: 42, z: 51 }; + +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); + await client.call('heroTeleport', GUARD.x, GUARD.z); + await client.call('stepTicks', 2); + + // Старт: сумка пуста, HUD скрыт, выбор «Отдать цветок» не показан + const empty = await client.call('invInfo'); + check('сумка пуста на старте', empty.empty === true && empty.size === 0, empty); + const hudEmptyShot = await client.screenshot('/tmp/v2_inv_empty.png'); + await client.call('interact'); + const noFlower = await client.call('dialogueInfo'); + check('без цветка выбор «Отдать цветок» скрыт (hasItem)', + noFlower.choices.length === 3 + && !noFlower.choices.some((c) => c.text === 'Отдать цветок'), noFlower.choices); + + // «Да, я копал» → dig даёт предмет (giveItem) → сумка и HUD + await client.call('dialoguePick', 0); + await client.call('dialogueAdvance'); + await client.call('dialogueAdvance'); + const got = await client.call('dialogueInfo'); + const inv1 = await client.call('invInfo'); + check('giveItem диалога наполнил сумку: цветок ×1', + got.active === false && inv1.all.length === 1 + && inv1.all[0].id === 'flower' && inv1.all[0].count === 1, inv1); + + // Кадр с предметом: HUD в левом верхнем углу, позиция камеры та же + const hudItemShot = await client.screenshot('/tmp/v2_inv_item.png'); + const hudEmpty = decodePng(readFileSync(hudEmptyShot)); + const hudItem = decodePng(readFileSync(hudItemShot)); + const hudDiff = countDiff(cropRows(hudItem, 0, 80, 0, 220), cropRows(hudEmpty, 0, 80, 0, 220)); + check('HUD сумки рисуется в углу кадра', hudDiff > 300, { hudDiff }); + + // Повторный диалог: выбор «Отдать цветок» появился (index 3) + await client.call('interact'); + const withFlower = await client.call('dialogueInfo'); + check('с цветком выбор «Отдать цветок» виден', + withFlower.choices.length === 4 + && withFlower.choices[3].text === 'Отдать цветок', withFlower.choices); + await client.call('dialoguePick', 3); // away: takeItem + await client.call('dialogueAdvance'); + await client.call('dialogueAdvance'); + const gave = await client.call('dialogueInfo'); + const inv2 = await client.call('invInfo'); + check('takeItem забрал цветок: сумка снова пуста', + gave.active === false && gave.flags.includes('flowerGivenAway') + && inv2.empty === true, { flags: gave.flags, inv: inv2 }); + const hudAfterShot = await client.screenshot('/tmp/v2_inv_after.png'); + const hudAfter = decodePng(readFileSync(hudAfterShot)); + const backDiff = countDiff(cropRows(hudAfter, 0, 80, 0, 220), cropRows(hudEmpty, 0, 80, 0, 220)); + check('HUD скрылся после отдачи: кадр вернулся', backDiff === 0, { backDiff }); + + 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); + +/** Строки y0..y1, колонки x0..x1 кадра (RGBA) — зона HUD без шума сцены. */ +function cropRows(img, y0, y1, x0, x1) { + const stride = img.width * 4; + const out = []; + for (let y = y0; y < y1; y++) { + out.push(...img.rgba.subarray(y * stride + x0 * 4, y * stride + x1 * 4)); + } + return out; +} + +/** Число различающихся RGBA-пикселей двух зон одного размера. */ +function countDiff(a, b) { + let n = 0; + for (let i = 0; i < Math.min(a.length, b.length); i += 4) { + if (Math.abs(a[i] - b[i]) > 8 || Math.abs(a[i + 1] - b[i + 1]) > 8 || Math.abs(a[i + 2] - b[i + 2]) > 8) n++; + } + return n; +} \ No newline at end of file diff --git a/v2/packages/engine/src/index.ts b/v2/packages/engine/src/index.ts index 5bb7f9d..1c9f3a9 100644 --- a/v2/packages/engine/src/index.ts +++ b/v2/packages/engine/src/index.ts @@ -62,4 +62,6 @@ export { DialogueRunner } from './dialogue/runner'; export type { DialogueView } from './dialogue/runner'; export { nearestInteractable, interactablesInRadius } from './interaction/interactables'; -export type { Interactable, InteractableHit } from './interaction/interactables'; \ No newline at end of file +export type { Interactable, InteractableHit } from './interaction/interactables'; +export { Inventory, inventoryFromData } from './inventory/inventory'; +export type { InventoryOptions, InventoryChange, InventoryChangeKind, InventoryData } from './inventory/inventory'; \ No newline at end of file diff --git a/v2/packages/engine/src/inventory/__tests__/inventory.test.ts b/v2/packages/engine/src/inventory/__tests__/inventory.test.ts new file mode 100644 index 0000000..f69857d --- /dev/null +++ b/v2/packages/engine/src/inventory/__tests__/inventory.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Inventory, inventoryFromData } from '../inventory'; + +describe('Inventory', () => { + it('добавляет и считает предметы', () => { + const inv = new Inventory(); + expect(inv.empty).toBe(true); + inv.add('cloth'); + inv.add('bellflower', 2); + expect(inv.count('cloth')).toBe(1); + expect(inv.count('bellflower')).toBe(2); + expect(inv.has('cloth')).toBe(true); + expect(inv.has('salt')).toBe(false); + expect(inv.empty).toBe(false); + }); + + it('remove вычитает и чистит пустые слоты', () => { + const inv = new Inventory(); + inv.add('bellflower', 3); + expect(inv.remove('bellflower', 2)).toBe(2); + expect(inv.count('bellflower')).toBe(1); + expect(inv.remove('bellflower', 5)).toBe(1); // нельзя убрать больше, чем есть + expect(inv.count('bellflower')).toBe(0); + expect(inv.has('bellflower')).toBe(false); + expect(inv.remove('cloth')).toBe(0); // нет предмета — ничего + }); + + it('add с неположительным n — no-op', () => { + const inv = new Inventory(); + inv.add('salt', 0); + inv.add('salt', -1); + expect(inv.empty).toBe(true); + }); + + it('onChange оповещает о каждом изменении, отписка работает', () => { + const inv = new Inventory(); + const fn = vi.fn(); + const off = inv.onChange(fn); + inv.add('cloth'); + inv.add('cloth'); + inv.remove('cloth'); + off(); + inv.add('cloth'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('serialize/load round-trip; load игнорирует мусор', () => { + const inv = new Inventory(); + inv.add('cloth'); + inv.add('bellflower', 3); + const data = inv.serialize(); + const back = inventoryFromData(JSON.parse(JSON.stringify(data))); + expect(back.all).toEqual([ + { id: 'cloth', count: 1 }, + { id: 'bellflower', count: 3 }, + ]); + back.load({ items: { salt: 0, bad: -2, cloth: 2 } }); + expect(back.count('salt')).toBe(0); + expect(back.count('bad')).toBe(0); + expect(back.count('cloth')).toBe(2); + }); + + it('clear сбрасывает содержимое', () => { + const inv = new Inventory(); + inv.add('cloth'); + inv.clear(); + expect(inv.empty).toBe(true); + }); + + it('maxPerStack обрезает добавление и возвращает факт', () => { + const inv = new Inventory({ maxPerStack: 3 }); + expect(inv.add('bellflower', 5)).toBe(3); + expect(inv.count('bellflower')).toBe(3); + expect(inv.add('bellflower', 1)).toBe(0); // стак полон + expect(inv.spaceFor('bellflower')).toBe(0); + expect(inv.spaceFor('salt')).toBe(3); + }); + + it('maxSlots не даёт заводить новый слот, но стек в существующий идёт', () => { + const inv = new Inventory({ maxSlots: 2 }); + inv.add('cloth'); + inv.add('salt'); + expect(inv.add('bellflower')).toBe(0); // слотов нет + expect(inv.has('bellflower')).toBe(false); + expect(inv.add('salt', 2)).toBe(2); // свой слот не лимитируем + }); + + it('load клампит под лимиты', () => { + const inv = new Inventory({ maxSlots: 2, maxPerStack: 5 }); + inv.load({ items: { a: 9, b: 2, c: 1 } }); + expect(inv.all).toEqual([ + { id: 'a', count: 5 }, + { id: 'b', count: 2 }, + ]); + expect(inv.size).toBe(2); + }); + + it('событие несёт деталь изменения для всех kind', () => { + const inv = new Inventory(); + const fn = vi.fn(); + inv.onChange(fn); + inv.add('cloth', 2); + expect(fn).toHaveBeenLastCalledWith({ kind: 'add', id: 'cloth', count: 2, after: 2 }); + inv.remove('cloth'); + expect(fn).toHaveBeenLastCalledWith({ kind: 'remove', id: 'cloth', count: -1, after: 1 }); + inv.remove('cloth', 1); + expect(fn).toHaveBeenLastCalledWith({ kind: 'remove', id: 'cloth', count: -1, after: 0 }); + inv.clear(); // пусто — clear не эмитится + inv.add('salt'); + inv.load({ items: {} }); + expect(fn).toHaveBeenLastCalledWith({ kind: 'load', id: '', count: 0, after: 0 }); + inv.add('salt'); + inv.clear(); + expect(fn).toHaveBeenLastCalledWith({ kind: 'clear', id: '', count: 0, after: 0 }); + }); + + it('генерик типизирует id (компиляция)', () => { + type Item = 'a' | 'b'; + const inv = new Inventory(); + inv.add('a', 2); + expect(inv.count('a')).toBe(2); + }); +}); \ No newline at end of file diff --git a/v2/packages/engine/src/inventory/inventory.ts b/v2/packages/engine/src/inventory/inventory.ts new file mode 100644 index 0000000..f6905bc --- /dev/null +++ b/v2/packages/engine/src/inventory/inventory.ts @@ -0,0 +1,155 @@ +/** + * Инвентарь — жанронезависимый контейнер предметов (перенос v1). + * Хранит счётчики по id: `Map`. Стеки неявные — количество. + * Лимиты (слоты/стак) обрезают добавление и возвращают факт — модель не бросает. + * Смена состава оповещает подписчиков (view-агностично): подписка — как у + * EventBus, но события эмитит сам контейнер, чтобы работать без внешней шины. + */ + +/** Лимиты контейнера (undefined/0 — без лимита). */ +export interface InventoryOptions { + /** Максимум разных слотов (id). */ + maxSlots?: number; + /** Максимум штук в одном слоте. */ + maxPerStack?: number; +} + +export type InventoryChangeKind = 'add' | 'remove' | 'clear' | 'load'; + +/** Деталь изменения: знаковая дельта + итог; clear/load — batch-событие (id '', count 0). */ +export interface InventoryChange { + kind: InventoryChangeKind; + id: string; + count: number; + /** Итоговый счётчик предмета после изменения (0 для clear/load). */ + after: number; +} + +/** Сериализованное состояние инвентаря (для сейвов; формат стабильный). */ +export interface InventoryData { + items: Record; +} + +/** Создать инвентарь из сериализованного состояния. */ +export function inventoryFromData( + data: InventoryData | undefined | null, + options: InventoryOptions = {}, +): Inventory { + const inv = new Inventory(options); + if (data) inv.load(data); + return inv; +} + +export class Inventory { + private counts = new Map(); + private listeners = new Set<(change: InventoryChange) => void>(); + private readonly slotLimitValue: number; + private readonly stackLimitValue: number; + + constructor(options: InventoryOptions = {}) { + this.slotLimitValue = options.maxSlots && options.maxSlots > 0 ? options.maxSlots : Infinity; + this.stackLimitValue = options.maxPerStack && options.maxPerStack > 0 ? options.maxPerStack : Infinity; + } + + /** Подписка на изменения. Возвращает отписку. */ + onChange(fn: (change: InventoryChange) => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + /** + * Добавить n штук (лимиты обрезают; n <= 0 — ничего). + * Возвращает, сколько реально влезло. + */ + add(itemId: TId, n = 1): number { + if (n <= 0) return 0; + const before = this.counts.get(itemId) ?? 0; + if (before === 0 && this.counts.size >= this.slotLimitValue) return 0; + const gained = Math.min(n, this.stackLimitValue - before); + if (gained <= 0) return 0; + this.counts.set(itemId, before + gained); + this.notify({ kind: 'add', id: itemId, count: gained, after: before + gained }); + return gained; + } + + /** Сколько ещё влезет этого предмета (лимиты стака и слотов). */ + spaceFor(itemId: TId): number { + const have = this.counts.get(itemId) ?? 0; + if (have > 0) return Math.max(0, this.stackLimitValue - have); + return this.counts.size >= this.slotLimitValue ? 0 : this.stackLimitValue; + } + + /** + * Убрать n штук предмета. Если после вычитания стало <= 0 — слот исчезает. + * Возвращает, сколько реально убрано (нельзя убрать больше, чем есть). + */ + remove(itemId: TId, n = 1): number { + const have = this.counts.get(itemId) ?? 0; + if (have <= 0 || n <= 0) return 0; + const left = Math.max(0, have - n); + if (left === 0) this.counts.delete(itemId); + else this.counts.set(itemId, left); + this.notify({ kind: 'remove', id: itemId, count: -Math.min(n, have), after: left }); + return Math.min(n, have); + } + + /** Сколько штук предмета лежит (0, если нет). */ + count(itemId: TId): number { + return this.counts.get(itemId) ?? 0; + } + + /** Есть ли предмет (в количестве >= 1). */ + has(itemId: TId): boolean { + return this.count(itemId) > 0; + } + + /** Все слоты (id → количество); порядок вставки. */ + get all(): Array<{ id: string; count: number }> { + return [...this.counts].map(([id, count]) => ({ id, count })); + } + + get empty(): boolean { + return this.counts.size === 0; + } + + /** Занято слотов (разных id). */ + get size(): number { + return this.counts.size; + } + + /** Лимит слотов (Infinity — без лимита). */ + get slotLimit(): number { + return this.slotLimitValue; + } + + /** Лимит стака (Infinity — без лимита). */ + get stackLimit(): number { + return this.stackLimitValue; + } + + /** Полностью очистить (новая игра). */ + clear(): void { + if (this.counts.size === 0) return; + this.counts.clear(); + this.notify({ kind: 'clear', id: '', count: 0, after: 0 }); + } + + serialize(): InventoryData { + return { items: Object.fromEntries(this.counts) }; + } + + /** Загрузить состояние (мусор и count <= 0 игнорируются; кламп под лимиты). */ + load(data: InventoryData): void { + this.counts.clear(); + for (const [id, count] of Object.entries(data.items ?? {})) { + if (typeof count !== 'number' || count <= 0) continue; + if (this.counts.size >= this.slotLimitValue) break; // берём первые N слотов + this.counts.set(id, Math.min(count, this.stackLimitValue)); + } + this.notify({ kind: 'load', id: '', count: 0, after: 0 }); + } + + private notify(change: InventoryChange): void { + for (const fn of this.listeners) fn(change); + } +} \ No newline at end of file