Модуль packages/engine/src/inventory/Inventory.ts — жанронезависимый контейнер предметов. Движок знает только модель: стаки, лимиты, события. Реестр предметов (имена, описания) и UI-сцены — на стороне игры (в этой игре: apps/game/src/data/items.ts, InventoryScene).
Содержимое — Map<itemId, count>: стаки неявные (количество), порядок слотов — порядок вставки.
export interface InventoryOptions {
/** Максимум разных слотов (id). undefined/0 — без лимита. */
maxSlots?: number;
/** Максимум штук в одном слоте. undefined/0 — без лимита. */
maxPerStack?: number;
}
const inv = new Inventory({ maxSlots: 12, maxPerStack: 99 });
Лимиты не бросают исключений — add обрезает добавление и возвращает, сколько реально влезло (все вызовы в игре игнорируют возврат — совместимо):
inv.add('cloth'); // → 1 (сколько влезло)
inv.add('cloth', 5); // частичное добавление под лимит
inv.spaceFor('cloth'); // сколько ещё влезет (стак + свободные слоты)
inv.remove('cloth', 2); // → убрано; пустой слот исчезает
inv.count('cloth'); inv.has('cloth');
inv.all; // [{ id, count }] — порядок вставки
inv.size; // занято слотов
inv.slotLimit; inv.stackLimit; // Infinity, если не заданы
inv.empty; inv.clear();
inv.serialize(); inv.load(data); // { items: Record<string, number> } — формат сейвов, не менять
load игнорирует мусор и count <= 0, клампит под лимиты (берёт первые N слотов), эмитит batch-событие. inventoryFromData(data, options?) — фабрика.
onChange(fn) возвращает отписку. Аргумент — деталь изменения:
export interface InventoryChange {
kind: 'add' | 'remove' | 'clear' | 'load';
/** Затронутый id; для batch-событий 'clear'/'load' — ''. */
id: string;
/** Знаковая дельта (+добавлено / -убрано); для 'clear'/'load' — 0. */
count: number;
/** Итоговый счётчик предмета после изменения. */
after: number;
}
const off = inv.onChange((ch) => redraw(ch.kind === 'load' ? 'всё' : ch.id));
add/remove эмитят по одному событию на предмет; clear/load — одно batch-событие (view просто перерисовывается целиком). События не эмитятся, когда ничего не изменилось (add 0 влезло, clear на пустом).
Класс генеричен: Inventory<TId extends string = string>. Игра может дать Inventory<ItemId> — опечатка в id ловится компилятором; дефолт — строки, чтобы движок не знал предметов.
ItemDef, форматирование строк (inventoryItems), UI-сцена, лимиты сумки как контент (new Inventory(...) в Game.ts).«Применение» предмета — чистый хелпер игры useItemLine(id, {label}) → string | null (apps/game/src/data/items.ts): юза́бельные предметы дают строку-результат (часы → «Время: 8:24»), остальные — null (сумка молчит). InventoryScene показывает сумку списком с курсором (MenuList), Enter вызывает хелпер и кладёт строку в PixelText внизу панели. Новые юза́бельные предметы расширяют useItemLine, а не сцену.
packages/engine/src/inventory/__tests__/Inventory.test.ts — add/count, remove, лимиты (частичное добавление, отказ на новый слот, load-кламп), детали всех 4 kind события, round-trip serialize/load, clear, генерик.