diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 26a734c..661dc9a 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -5,6 +5,7 @@ AudioManager, GameState, Settings, + Inventory, parseMap, type Renderer, type SceneManager, @@ -28,6 +29,8 @@ readonly audio = new AudioManager((key) => `${import.meta.env.BASE_URL}audio/${key}.wav`); /** Флаги и переменные прохождения. */ readonly state = new GameState(); + /** Сумка героя (предметы квестов и торговли). */ + readonly inventory = new Inventory(); /** Настройки игрока (громкости и т.п.) — вне сейвов. */ readonly settings = new Settings(window.localStorage); diff --git a/apps/game/src/data/items.ts b/apps/game/src/data/items.ts new file mode 100644 index 0000000..3e7b298 --- /dev/null +++ b/apps/game/src/data/items.ts @@ -0,0 +1,39 @@ +/** + * Реестр предметов (контент по docs/world.md). Id — стабильные ключи сейва. + * Описания — стиль сеттинга: кратко, меланхолично. + */ +export interface ItemDef { + id: ItemId; + name: string; + desc: string; +} + +export type ItemId = 'cloth' | 'bellflower' | 'salt' | 'water_flask'; + +export const ITEMS: Record = { + cloth: { + id: 'cloth', + name: 'Вощёное полотно', + desc: 'Маска от наката. Дышать поверх свежего пепла — потерять голос.' + }, + bellflower: { + id: 'bellflower', + name: 'Лунный колокольчик', + desc: 'Трава-возвращенец. Вытягивает пепел из земли. Посаженный — надежда.' + }, + salt: { + id: 'salt', + name: 'Соль', + desc: 'Край держится на обмене. Соль здесь дороже золота.' + }, + water_flask: { + id: 'water_flask', + name: 'Вода в медном бутле', + desc: 'Мутная, но чистая. Пруды отдают воду неохотно.' + } +}; + +/** Безопасное имя по id (незнакомый id — сам id). */ +export function itemName(id: string): string { + return ITEMS[id as ItemId]?.name ?? id; +} \ No newline at end of file diff --git a/apps/game/src/data/quests.ts b/apps/game/src/data/quests.ts index 80c4de4..c32feae 100644 --- a/apps/game/src/data/quests.ts +++ b/apps/game/src/data/quests.ts @@ -1,9 +1,10 @@ -import type { GameState } from '@rpg/engine'; +import type { GameState, Inventory } from '@rpg/engine'; +import { itemName } from './items'; /** * Квест «Три цветка» (docs/world.md, акт 1): Ирвин просит собрать * лунные колокольчики у Серых прудов и посадить их на лугу. - * Прогресс — во флагах/варах GameState, чтобы переживал сейвы. + * Прогресс — во флагах/варах GameState, сумка — в Inventory. */ export const QUEST_FLOWERS = 3; @@ -29,12 +30,11 @@ return entries; } -/** Предметы инвентаря по флагам/варам GameState. */ -export function inventoryItems(state: GameState): string[] { - const items: string[] = []; - if (state.hasFlag('got_cloth')) items.push('Вощёное полотно'); - const flowers = state.getNumber('flowers'); - if (flowers > 0) items.push(`Лунный колокольчик ×${flowers}`); +/** Строки сумки из инвентаря: имя + ×N (у одиночных — без множителя). */ +export function inventoryItems(inventory: Inventory): string[] { + const items = inventory.all.map((slot) => + slot.count > 1 ? `${itemName(slot.id)} ×${slot.count}` : itemName(slot.id) + ); if (items.length === 0) items.push('— пусто —'); return items; } \ No newline at end of file diff --git a/apps/game/src/scenes/InventoryScene.ts b/apps/game/src/scenes/InventoryScene.ts index 274ad72..addfc57 100644 --- a/apps/game/src/scenes/InventoryScene.ts +++ b/apps/game/src/scenes/InventoryScene.ts @@ -27,7 +27,7 @@ // Инвентарь addLine('Сумка', 0x999988, 16, 36); let y = 50; - for (const item of inventoryItems(this.game.state)) { + for (const item of inventoryItems(this.game.inventory)) { addLine(item, 0xd8c79a, 24, y); y += 13; } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index d869c7c..317340d 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -24,7 +24,7 @@ type Vec2 } from '@rpg/engine'; import { Game } from '../Game'; -import { MenuScene, type SaveData } from './MenuScene'; +import { MenuScene, SAVE_VERSION, type SaveData } from './MenuScene'; import { InventoryScene } from './InventoryScene'; import { TILES } from '../data/map'; import { locationOf, type LocationDef } from '../data/locations'; @@ -558,9 +558,10 @@ return this.map.data.tiles[y * this.map.data.width + x]; } - /** Сбор лунного колокольчика: тайл зеленеет, прогресс квеста — в vars. */ + /** Сбор лунного колокольчика: тайл зеленеет, цветок — в сумку, прогресс — в vars. */ private collectFlower(x: number, y: number): void { this.map.setTile(x, y, TILES.GRASS); + this.game.inventory.add('bellflower'); const n = this.game.state.getNumber('flowers') + 1; this.game.state.setVar('flowers', n); this.game.engine.events.emit('quest:flower', { n }); @@ -589,6 +590,9 @@ private onDialogueFinished(id: string): void { if (id === 'elder_hand_in') this.plantFlowers(); + // Полотно — предмет сумки (флаг got_cloth остаётся как метка знакомства). + // TODO(батч 2): выдача предметов уходит в квест-модель. + if (id === 'trader_first') this.game.inventory.add('cloth'); } /** Посадка цветов у тропы: тайлы гудят колокольчиками (крючок акта 1). */ @@ -642,9 +646,11 @@ const pos = this.player.currentTile(); this.game.state.setVar('hp', this.playerCombat.hp); this.game.saves.save('autosave', { + version: SAVE_VERSION, location: this.location.id, pos, state: this.game.state.serialize(), + items: this.game.inventory.serialize().items, savedAt: Date.now() } satisfies SaveData); void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); diff --git a/apps/game/src/scenes/MenuScene.ts b/apps/game/src/scenes/MenuScene.ts index 1b43ef2..57ece25 100644 --- a/apps/game/src/scenes/MenuScene.ts +++ b/apps/game/src/scenes/MenuScene.ts @@ -41,6 +41,7 @@ onSelect: () => { void this.game.audio.play('sfx/ui_click'); this.game.state.reset(); + this.game.inventory.clear(); this.start(null); } }, @@ -99,9 +100,18 @@ } private start(save: SaveData | null): void { - void this.game.scenes.replace(new LocationScene(this.game, save, locationOf(save?.location)), { - duration: 0.4 - }); + if (save) { + const norm = normalizeSave(save); + this.game.inventory.load({ items: norm.items }); + void this.game.scenes.replace(new LocationScene(this.game, norm, locationOf(norm.location)), { + duration: 0.4 + }); + } else { + this.game.inventory.clear(); + void this.game.scenes.replace(new LocationScene(this.game, null, locationOf(undefined)), { + duration: 0.4 + }); + } } update(_dt: number): void { @@ -119,11 +129,26 @@ } } -/** Формат сейва: локация, позиция героя и сериализованное состояние прохождения. */ +/** + * Формат сейва: локация, позиция героя, сериализованное состояние прохождения + * и сумка. У старых сейвов нет `version`/`items` — normalizeSave дорастает их. + */ +export const SAVE_VERSION = 2; + export interface SaveData { + version: number; /** В какой локации герой (фолбэк 'meadows' для старых сейвов). */ location: string; pos: { x: number; y: number }; state: GameStateData; + /** Содержимое сумки: itemId -> количество. */ + items: Record; savedAt: number; +} + +/** Дополнить сейв старого формата до текущего (без записи). */ +export function normalizeSave(save: SaveData): SaveData { + if (save.version === SAVE_VERSION) return save; + // v1 (до сумки): location/pos/state/savedAt без version и items. + return { ...save, version: SAVE_VERSION, items: save.items ?? {} }; } \ No newline at end of file diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 9425eb7..64dcb03 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -26,6 +26,9 @@ export { Cooldown } from './core/Cooldown'; export { Settings, type SettingsData } from './core/Settings'; +// inventory +export { Inventory, inventoryFromData, type InventoryData } from './inventory/Inventory'; + // scene export { SceneManager, type Scene, type SceneTransition } from './scene/SceneManager'; diff --git a/packages/engine/src/inventory/Inventory.ts b/packages/engine/src/inventory/Inventory.ts new file mode 100644 index 0000000..42418d1 --- /dev/null +++ b/packages/engine/src/inventory/Inventory.ts @@ -0,0 +1,97 @@ +/** + * Инвентарь — жанронезависимый контейнер предметов. + * Хранит счётчики по id: `Map`. Стеки неявные — количество. + * Смена состава оповещает подписчиков (view-агностично): подписка — как у EventBus, + * но события эмитит сам контейнер, чтобы работать без внешней шины. + */ + +export interface InventoryState { + /** Предмет -> количество (только > 0). */ + items: Record; +} + +/** Создать инвентарь из сериализованного состояния. */ +export function inventoryFromData(data: InventoryData | undefined | null): Inventory { + const inv = new Inventory(); + if (data) inv.load(data); + return inv; +} + +/** Сериализованное состояние инвентаря (для сейвов). */ +export interface InventoryData { + items: Record; +} + +export class Inventory { + private counts = new Map(); + private listeners = new Set<() => void>(); + + /** Подписка на любое изменение содержимого. Возвращает отписку. */ + onChange(fn: () => 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 штук предмета. Если после вычитания стало <= 0 — слот исчезает. + * Возвращает, сколько реально убрано (нельзя убрать больше, чем есть). + */ + remove(itemId: string, 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(); + return Math.min(n, have); + } + + /** Сколько штук предмета лежит (0, если нет). */ + count(itemId: string): number { + return this.counts.get(itemId) ?? 0; + } + + /** Есть ли предмет (в количестве >= 1). */ + has(itemId: string): 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; + } + + /** Полностью очистить (новая игра). */ + clear(): void { + if (this.counts.size === 0) return; + this.counts.clear(); + this.notify(); + } + + serialize(): InventoryData { + return { items: Object.fromEntries(this.counts) }; + } + + 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); + } + this.notify(); + } + + private notify(): void { + for (const fn of this.listeners) fn(); + } +} \ 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 new file mode 100644 index 0000000..cbe1457 --- /dev/null +++ b/packages/engine/src/inventory/__tests__/Inventory.test.ts @@ -0,0 +1,69 @@ +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); + }); +}); \ No newline at end of file