diff --git a/apps/game/src/data/__tests__/items.test.ts b/apps/game/src/data/__tests__/items.test.ts new file mode 100644 index 0000000..2e52f50 --- /dev/null +++ b/apps/game/src/data/__tests__/items.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { ITEMS, itemName, useItemLine } from '../items'; + +describe('useItemLine — применение предметов', () => { + it('часы показывают замороженное время сумки', () => { + expect(useItemLine('clock', { label: '8:24' })).toBe('Время: 8:24'); + expect(useItemLine('clock', { label: '0:05' })).toBe('Время: 0:05'); + }); + + it('не-юзабельные предметы — null (сумка молчит)', () => { + expect(useItemLine('cloth', { label: '8:24' })).toBeNull(); + expect(useItemLine('flower', { label: '8:24' })).toBeNull(); + expect(useItemLine('', { label: '8:24' })).toBeNull(); + }); +}); + +describe('реестр предметов', () => { + it('часы описаны в реестре (валидатор диалогов держит giveItem clock)', () => { + expect(itemName('clock')).toBe('Медные часы'); + expect(ITEMS.clock.desc.length).toBeGreaterThan(0); + }); +}); \ No newline at end of file diff --git a/apps/game/src/data/dialogues/elder_first.json b/apps/game/src/data/dialogues/elder_first.json index 63bfcee..fe6e0e1 100644 --- a/apps/game/src/data/dialogues/elder_first.json +++ b/apps/game/src/data/dialogues/elder_first.json @@ -23,6 +23,9 @@ }, "give": { "setFlags": ["met_elder", "quest_bells_taken"], + "do": [{ "kind": "giveItem", "id": "clock" }], + "speaker": "Старейшина Ирвин", + "text": "Держи старые медные часы. Стрелка не врёт: темнота по ней приходит.", "next": "ring" }, "ring": { diff --git a/apps/game/src/data/items.ts b/apps/game/src/data/items.ts index 2dc6297..755b717 100644 --- a/apps/game/src/data/items.ts +++ b/apps/game/src/data/items.ts @@ -8,9 +8,14 @@ desc: string; } -export type ItemId = 'cloth' | 'bellflower' | 'salt' | 'water_flask' | 'mote'; +export type ItemId = 'cloth' | 'bellflower' | 'salt' | 'water_flask' | 'mote' | 'clock'; export const ITEMS: Record = { + clock: { + id: 'clock', + name: 'Медные часы', + desc: 'Старые, с одной стрелкой. Ирвин: «Стрелка не врёт — темнота по ней приходит».' + }, cloth: { id: 'cloth', name: 'Вощёное полотно', @@ -41,4 +46,13 @@ /** Безопасное имя по id (незнакомый id — сам id). */ export function itemName(id: string): string { return ITEMS[id as ItemId]?.name ?? id; +} + +/** + * Строка от «применения» предмета (юза́бельные — только часы). Чистая функция: + * null — предмет без эффекта (сумка молчит). label — текущее время сумки + * (в сумке время заморожено — LocationScene не тикается). + */ +export function useItemLine(id: string, ctx: { label: string }): string | null { + return id === 'clock' ? `Время: ${ctx.label}` : null; } \ No newline at end of file diff --git a/apps/game/src/scenes/InventoryScene.ts b/apps/game/src/scenes/InventoryScene.ts index 5ef46a7..b17caff 100644 --- a/apps/game/src/scenes/InventoryScene.ts +++ b/apps/game/src/scenes/InventoryScene.ts @@ -1,12 +1,14 @@ import { playSfx } from '../data/sfxSpecs'; -import { MenuSceneBase, Panel, PixelText } from '@rpg/engine'; +import { MenuList, MenuSceneBase, Panel, PixelText } from '@rpg/engine'; import { Game } from '../Game'; -import { inventoryItems, questLog } from '../data/quests'; +import { questLog } from '../data/quests'; +import { itemName, useItemLine } from '../data/items'; /** * Сумка и журнал квестов (панель поверх локации, push/pop). * Содержимое собирается из флагов/варов GameState при каждом открытии. - * Список-просмотр без курсора: закрытие — Esc/инвентарь (через каркас). + * Список сумки с курсором: Enter — применить предмет (часы показывают время, + * время в сумке заморожено — локация не тикается). Журнал — текст без фокуса. */ export class InventoryScene extends MenuSceneBase { constructor( @@ -34,24 +36,37 @@ panel.addChild(t); }; - // Инвентарь + // Сумка: список с курсором, Enter — применить выбранный предмет. addLine('Сумка', 0x999988, 16, 36); - let y = 50; - for (const item of inventoryItems(this.game.inventory)) { - addLine(item, 0xd8c79a, 24, y); - y += 13; + const slots = this.game.inventory.all; + const result = new PixelText({ text: '', size: 10, color: 0xf2d49a }); + result.position.set(20, 150); + panel.addChild(result); + if (slots.length > 0) { + this.menu = new MenuList({ width: 240, height: 14, gap: 1, size: 10 }); + this.menu.position.set(20, 48); + this.menu.setItems( + slots.map((slot) => ({ + label: slot.count > 1 ? `${itemName(slot.id)} ×${slot.count}` : itemName(slot.id), + onSelect: () => this.useItem(slot.id, result) + })) + ); + panel.addChild(this.menu); + } else { + addLine('— пусто —', 0x777788, 24, 50); } - // Журнал квестов - addLine('Журнал', 0x999988, 16, y + 6); - y += 21; + // Журнал квестов — ниже сумки, без фокуса. + const menuBottom = 48 + Math.max(slots.length, 1) * 15; + addLine('Журнал', 0x999988, 16, menuBottom + 2); + let y = menuBottom + 17; for (const entry of questLog(this.game.state)) { const mark = entry.done ? 'x' : '·'; addLine(`[${mark}] ${entry.text}`, entry.done ? 0xd8c79a : 0xaaaaaa, 24, y); y += 13; } - const hint = new PixelText({ text: 'Esc — назад', size: 9, color: 0x777788 }); + const hint = new PixelText({ text: 'Enter — применить, Esc — назад', size: 9, color: 0x777788 }); hint.anchor.set(0.5); hint.position.set(140, 168); panel.addChild(hint); @@ -60,6 +75,14 @@ this.game.renderer.uiRoot.addChild(this.view); } + /** Применить предмет: юза́бельные дают строку-результат, остальные молчат. */ + private useItem(id: string, result: PixelText): void { + const line = useItemLine(id, { label: this.game.clock.label }); + if (line === null) return; + playSfx(this.game.audio, 'sfx/ui_click'); + result.text = line; + } + /** Esc или клавиша инвентаря — назад в локацию. */ protected onCancel(): void { this.close(); diff --git a/apps/game/tools/checks/time.mjs b/apps/game/tools/checks/time.mjs index b221893..09184a7 100644 --- a/apps/game/tools/checks/time.mjs +++ b/apps/game/tools/checks/time.mjs @@ -8,7 +8,9 @@ * 3) 19:00 — середина заката: night ≈ 0.5, ambient — микс по фактическому * фактору (время идёт и после пина, точное равенство неустойчиво); * 4) 21:00 — ночь: night 1, лампа героя зажглась на светлой днём улице; - * 5) инварианты чисты (time-bounded и прочие). + * 5) часы (scene:give) кладутся в сумку и видны в снапшоте; + * 6) сумка открыта — время заморожено (локация не тикается), закрыта — идёт; + * 7) инварианты чисты (time-bounded и прочие). * Запуск: node tools/agent.mjs run tools/checks/time.mjs */ import { startDevServer, openGame, Checks } from '../lib.mjs'; @@ -63,6 +65,25 @@ c.expect(w.ok, 'в 21:00 луга не ночные или лампа не зажглась', w.snapshot?.lighting); return null; }); + await c.run('часы в сумке: scene:give кладёт предмет', async () => { + await ctx.agent.command('scene:give', { id: 'clock' }); + const s = await ctx.agent.snapshot(); + c.expect(s.inventory.some((i) => i.id === 'clock' && i.count === 1), 'часов нет в снапшоте сумки', s.inventory); + return null; + }); + await c.run('сумка: время заморожено, после закрытия — идёт', async () => { + await ctx.agent.command('scene:setTime', { hours: 10 }); // пин перед открытием + await ctx.agent.press('inventory'); // открыть сумку (атомарно) + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 120 }); + await ctx.agent.press('advance'); // применить часы (первый пункт) — строка в панели + await ctx.agent.step(120); // 2 с: в сумке локация не тикается + const s = await ctx.agent.snapshot(); + c.expect(s.time.minutes === 600, `в сумке время уехало (${s.time.label})`, s.time); + await ctx.agent.press('inventory'); // закрыть сумку + const w = await ctx.agent.waitFor('s.time.minutes > 600', { timeoutTicks: 300 }); + c.expect(w.ok, 'после закрытия сумки время не пошло', w.snapshot?.time); + return null; + }); await c.run('инварианты чисты при идущем времени', async () => { const inv = await ctx.agent.invariants(); const bad = inv.filter((i) => i.severity === 'error'); diff --git a/docs/demo.md b/docs/demo.md index 639915a..edf00dc 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -26,7 +26,8 @@ | Tween + easing | хит-флэш, кольца резонанса, всплывающие тосты, кат-сцены | готово | | Particles (атмосфера + burst) | пепел лугов, туман прудов, удары/смерти сгустков | готово | | Lighting (ambient multiply + аддитивные источники) | сумерки Звенца и окна домов, тёмный интерьер с очагом, лампа героя в темноте, отсвет башни после звонка | готово | -| Lighting II (импульсы, день/ночь, виньетка, тёмные пятна) | вечер после сдачи квеста (ночной ambient + светящиеся колокольчики), окна гаснут днём, вспышки ударов/резонанса/урона, виньетка при низком hp и в накате, тёмные ауры зон наката | готово | +| Lighting II (импульсы, день/ночь, виньетка, тёмные пятна) | непрерывное время суток (1 с = 1 игровая минута): закат/ночь по часам, ночной ambient + светящиеся колокольчики, окна гаснут днём, вспышки ударов/резонанса/урона, виньетка при низком hp и в накате, тёмные ауры зон наката | готово | +| Время суток (GameClock + dayNightFactor) | старт 8:00, часы в HUD нет — предмет «Медные часы» (Ирвин даёт с квестом): Enter в сумке показывает время; в сумке время заморожено | готово | | StateMachine | `EnemyBrain` — ИИ сгустков (dormant/rise/chase/…/sleep/dead) | готово | | ECS (World) | боевые сущности: враги и снаряды на `Entity` | готово | | Panel/Button/MenuList | меню, настройки, сейвы, сумка | готово | diff --git a/docs/engine/inventory.md b/docs/engine/inventory.md index 69e6d81..d341899 100644 --- a/docs/engine/inventory.md +++ b/docs/engine/inventory.md @@ -74,6 +74,15 @@ - Игра: реестр `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, diff --git a/docs/engine/ui-and-dialogue.md b/docs/engine/ui-and-dialogue.md index ee001e0..47f8d17 100644 --- a/docs/engine/ui-and-dialogue.md +++ b/docs/engine/ui-and-dialogue.md @@ -103,7 +103,11 @@ Сцены под push-сценой **не обновляются** (`SceneManager.update` тикает только верхнюю) — «пауза под меню» получается бесплатно; это осознанный контракт: -флага «обновлять сцену под меню» в движке нет. +флага «обновлять сцену под меню» в движке нет. Игра использует это и для +времени суток: в сумке (`InventoryScene`, список с курсором — пункты из +`inventory.all`, Enter — `useItemLine` → строка-результат в панели) часы +заморожены, в диалогах/катсценах — идут (тик часов стоит до ранних выходов +`LocationScene.update`). ## DialogueRunner (графы диалогов) diff --git a/docs/world.md b/docs/world.md index 793c5a1..79c3a01 100644 --- a/docs/world.md +++ b/docs/world.md @@ -82,7 +82,9 @@ ## 6. Сюжетная арка акта 1 (прототип) 1. **Пепел дышит.** Ирвин: три поляны лунных колокольчиков у Серых прудов задохнутся, - если не собрать цветы до выдоха тихой степи. → Квест «Три цветка». + если не собрать цветы до выдоха тихой степи. → Квест «Три цветка». С квестом даёт + старые медные часы: «Стрелка не врёт — темнота по ней приходит» (в игре: предмет + «часы», показывающий время суток). 2. **Мила предупреждает.** Шёпотом: у прудов лежит свежий накат, дышать поверх — потерять голос. Даёт вощёное полотно (маска). Первое знакомство с безгласными. 3. **Сбор цветов** — тихая, медичная вылазка: туман, звук шагов, звон колокольчика