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