diff --git a/docs/engine/README.md b/docs/engine/README.md index 140930b..7e87807 100644 --- a/docs/engine/README.md +++ b/docs/engine/README.md @@ -68,6 +68,7 @@ input/ InputManager (клавиатура/мышь/тач/геймпад), VirtualJoystick map/ IsometricTileMap, pathfinding (A*), mapFormat (JSON+RLE), tiled-импортёр math/ Vec2, изометрия, seeded RNG + inventory/ Inventory (контейнер предметов: стаки, лимиты, события) dialogue/ DialogueRunner (графы диалогов, view-агностик) cutscene/ CutsceneRunner (кат-сцены из шагов, view-агностик) ui/ DialogueBox, Panel, Button, MenuList, PixelText diff --git a/docs/engine/inventory.md b/docs/engine/inventory.md new file mode 100644 index 0000000..69e6d81 --- /dev/null +++ b/docs/engine/inventory.md @@ -0,0 +1,81 @@ +# Инвентарь (модель) + +Модуль `packages/engine/src/inventory/Inventory.ts` — жанронезависимый +контейнер предметов. Движок знает только **модель**: стаки, лимиты, события. +Реестр предметов (имена, описания) и UI-сцены — на стороне игры +(в этой игре: `apps/game/src/data/items.ts`, `InventoryScene`). + +## Модель + +Содержимое — `Map`: стаки неявные (количество), порядок слотов +— порядок вставки. + +```ts +export interface InventoryOptions { + /** Максимум разных слотов (id). undefined/0 — без лимита. */ + maxSlots?: number; + /** Максимум штук в одном слоте. undefined/0 — без лимита. */ + maxPerStack?: number; +} + +const inv = new Inventory({ maxSlots: 12, maxPerStack: 99 }); +``` + +Лимиты **не бросают исключений** — `add` обрезает добавление и возвращает, +сколько реально влезло (все вызовы в игре игнорируют возврат — совместимо): + +```ts +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 } — формат сейвов, не менять +``` + +`load` игнорирует мусор и `count <= 0`, клампит под лимиты (берёт первые N +слотов), эмитит batch-событие. `inventoryFromData(data, options?)` — фабрика. + +## События + +`onChange(fn)` возвращает отписку. Аргумент — деталь изменения: + +```ts +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` на пустом). + +## Типизация id + +Класс генеричен: `Inventory`. Игра может дать +`Inventory` — опечатка в id ловится компилятором; дефолт — строки, +чтобы движок не знал предметов. + +## Граница движок/игра + +- Движок: контейнер (этот модуль). Никаких имён предметов, цен, весов. +- Игра: реестр `ItemDef`, форматирование строк (`inventoryItems`), UI-сцена, + лимиты сумки как контент (`new Inventory(...)` в `Game.ts`). + +## Тесты + +`packages/engine/src/inventory/__tests__/Inventory.test.ts` — add/count, +remove, лимиты (частичное добавление, отказ на новый слот, load-кламп), +детали всех 4 kind события, round-trip serialize/load, clear, генерик. \ No newline at end of file diff --git a/docs/llms.txt b/docs/llms.txt index 67f337c..8da2b19 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -17,6 +17,7 @@ - [Рендер](engine/render.md): Renderer, Camera, IsoDepthLayer, Particles, pixel-perfect масштаб. - [Ввод](engine/input.md): действия, геймпад, VirtualJoystick, инъекция ввода. - [Карты](engine/maps.md): изометрия, A*, коллизии (круг поверх сетки), формат rpg-map, Tiled-импорт. +- [Инвентарь](engine/inventory.md): модель Inventory — стаки, лимиты, события; реестр предметов и UI — в игре. - [UI и диалоги](engine/ui-and-dialogue.md): PixelText, Panel, Button, MenuList, DialogueBox, DialogueRunner. - [Кат-сцены](engine/cutscene.md): раннер кат-сцен. - [Реестр объектов сцены](engine/registry.md): SceneRegistry — «кто на карте», индекс по тайлам. diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 239df47..cde1b36 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -48,7 +48,14 @@ export { Settings, type SettingsData } from './core/Settings'; // inventory -export { Inventory, inventoryFromData, type InventoryData } from './inventory/Inventory'; +export { + Inventory, + inventoryFromData, + type InventoryData, + type InventoryOptions, + type InventoryChange, + type InventoryChangeKind +} from './inventory/Inventory'; // cutscene export { CutsceneRunner, type CutsceneStep } from './cutscene/CutsceneRunner'; diff --git a/packages/engine/src/inventory/Inventory.ts b/packages/engine/src/inventory/Inventory.ts index 42418d1..892b0a6 100644 --- a/packages/engine/src/inventory/Inventory.ts +++ b/packages/engine/src/inventory/Inventory.ts @@ -1,65 +1,105 @@ /** * Инвентарь — жанронезависимый контейнер предметов. * Хранит счётчики по id: `Map`. Стеки неявные — количество. + * Лимиты (слоты/стак) обрезают добавление и возвращают факт — модель не бросает. * Смена состава оповещает подписчиков (view-агностично): подписка — как у EventBus, * но события эмитит сам контейнер, чтобы работать без внешней шины. */ -export interface InventoryState { - /** Предмет -> количество (только > 0). */ - items: Record; +/** Лимиты контейнера (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 function inventoryFromData(data: InventoryData | undefined | null): Inventory { - const inv = new Inventory(); +export function inventoryFromData( + data: InventoryData | undefined | null, + options: InventoryOptions = {} +): Inventory { + const inv = new Inventory(options); if (data) inv.load(data); return inv; } -/** Сериализованное состояние инвентаря (для сейвов). */ +/** Сериализованное состояние инвентаря (для сейвов; формат стабильный). */ export interface InventoryData { items: Record; } -export class Inventory { +export class Inventory { private counts = new Map(); - private listeners = new Set<() => void>(); + private listeners = new Set<(change: InventoryChange) => void>(); + private slotLimitValue: number; + private stackLimitValue: number; - /** Подписка на любое изменение содержимого. Возвращает отписку. */ - onChange(fn: () => void): () => void { + 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 >= 1). */ - add(itemId: string, n = 1): void { - if (n <= 0) return; - this.counts.set(itemId, (this.counts.get(itemId) ?? 0) + n); - this.notify(); + /** + * Добавить 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: string, n = 1): number { + 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(); + this.notify({ kind: 'remove', id: itemId, count: -Math.min(n, have), after: left }); return Math.min(n, have); } /** Сколько штук предмета лежит (0, если нет). */ - count(itemId: string): number { + count(itemId: TId): number { return this.counts.get(itemId) ?? 0; } /** Есть ли предмет (в количестве >= 1). */ - has(itemId: string): boolean { + has(itemId: TId): boolean { return this.count(itemId) > 0; } @@ -72,26 +112,44 @@ 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(); + 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) this.counts.set(id, count); + 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(); + this.notify({ kind: 'load', id: '', count: 0, after: 0 }); } - private notify(): void { - for (const fn of this.listeners) fn(); + private notify(change: InventoryChange): void { + for (const fn of this.listeners) fn(change); } } \ No newline at end of file diff --git a/packages/engine/src/inventory/__tests__/Inventory.test.ts b/packages/engine/src/inventory/__tests__/Inventory.test.ts index cbe1457..9e3a97a 100644 --- a/packages/engine/src/inventory/__tests__/Inventory.test.ts +++ b/packages/engine/src/inventory/__tests__/Inventory.test.ts @@ -66,4 +66,58 @@ 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