diff --git a/apps/game/src/data/__tests__/quests.test.ts b/apps/game/src/data/__tests__/quests.test.ts index 9588602..157dc77 100644 --- a/apps/game/src/data/__tests__/quests.test.ts +++ b/apps/game/src/data/__tests__/quests.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { GameState, Inventory } from '@rpg/engine'; -import { QUESTS, QUEST_FLOWERS, activeStage, inventoryItems, questDialogueFor, questDone, questEffectFor, questLog, questTaken } from '../quests'; +import { QUESTS, QUEST_FLOWERS, activeStage, inventoryItems, questDialogueFor, questDone, questLog, questTaken } from '../quests'; +import { applyDialogueEffect, type DialogueEffectSink } from '../../systems/DialogueEffects'; +import { DIALOGUE_CUSTOM } from '../effects'; const bells = QUESTS[0]!; @@ -49,10 +51,36 @@ }); describe('эффекты диалогов', () => { - it('эффекты берутся из стадий реестра и знакомств', () => { - expect(questEffectFor('elder_hand_in')).toBe('plant_flowers'); - expect(questEffectFor('trader_first')).toBe('give_cloth'); - expect(questEffectFor('elder_first')).toBeNull(); + it('applyDialogueEffect: give/take меняют сумку, unknown игнорируется', () => { + const inv = new Inventory(); + const sink: DialogueEffectSink = { + giveItem: (id, count) => void inv.add(id, count), + takeItem: (id, count) => void inv.remove(id, count), + playSound: () => {}, + showToast: () => {}, + custom: () => {} + }; + applyDialogueEffect({ kind: 'giveItem', id: 'cloth', count: 2 }, sink); + expect(inv.count('cloth')).toBe(2); + applyDialogueEffect({ kind: 'takeItem', id: 'cloth' }, sink); + expect(inv.count('cloth')).toBe(1); + // неизвестный kind — тихий игнор (валидатор ловит как error) + applyDialogueEffect({ kind: 'custom' as 'custom', id: 'nope' } as never, sink); + applyDialogueEffect({ kind: 'toast', text: '' }, sink); + expect(inv.count('cloth')).toBe(1); + }); + + it('custom доходит до приёмника с именем из реестра', () => { + const got: string[] = []; + const sink: DialogueEffectSink = { + giveItem: () => {}, + takeItem: () => {}, + playSound: () => {}, + showToast: () => {}, + custom: (name) => got.push(name) + }; + applyDialogueEffect({ kind: 'custom', id: DIALOGUE_CUSTOM.plant_flowers }, sink); + expect(got).toEqual(['plant_flowers']); }); it('квест завершён, но эпилог (без doneFlag) остаётся активной стадией', () => { diff --git a/apps/game/src/data/dialogues.ts b/apps/game/src/data/dialogues.ts index 793cbf4..33cc27f 100644 --- a/apps/game/src/data/dialogues.ts +++ b/apps/game/src/data/dialogues.ts @@ -1,5 +1,6 @@ import type { DialogueGraph } from '@rpg/engine'; import { FLAGS, VARS } from './ids'; +import { DIALOGUE_CUSTOM } from './effects'; /** * Диалоги NPC как графы для DialogueRunner (по docs/world.md, акт 1). @@ -47,7 +48,11 @@ text: 'Три цветка. Живые. Сажай у тропы, звонарь. Серая земля примет.', next: 'accept' }, - accept: { setFlags: [FLAGS.quest_bells_done], next: 'ring' }, + accept: { + setFlags: [FLAGS.quest_bells_done], + do: [{ kind: 'custom', id: DIALOGUE_CUSTOM.plant_flowers }], + next: 'ring' + }, ring: { speaker: 'Звонарь', text: 'Пусть гудят. Это твой голос, Ирвин, — теперь в земле.' } } }, @@ -70,7 +75,11 @@ text: 'Держи вощёное полотно. На губы. И звони тихо — пепел не буди.', next: 'give_cloth' }, - give_cloth: { setFlags: [FLAGS.met_mila, FLAGS.got_cloth], next: 'reply' }, + give_cloth: { + setFlags: [FLAGS.met_mila, FLAGS.got_cloth], + do: [{ kind: 'giveItem', id: 'cloth' }], + next: 'reply' + }, reply: { speaker: 'Звонарь', text: 'Спасибо, Мила. Верну и полотно, и голос — твой точно.' } } }, diff --git a/apps/game/src/data/effects.ts b/apps/game/src/data/effects.ts new file mode 100644 index 0000000..87304c9 --- /dev/null +++ b/apps/game/src/data/effects.ts @@ -0,0 +1,10 @@ +/** + * Реестр имён сюжетных эффектов диалогов (как FLAGS/VARS — валидатор ловит + * опечатки). Имена сцена реализует в своём EffectSink; give/take/sound/toast + * — общие и сюда не входят. + */ +export const DIALOGUE_CUSTOM = { + plant_flowers: 'plant_flowers' // сдача «Трёх цветков»: кат-сцена посадки +} as const; + +export type DialogueCustomId = keyof typeof DIALOGUE_CUSTOM; \ No newline at end of file diff --git a/apps/game/src/data/quests.ts b/apps/game/src/data/quests.ts index 65b9ca0..0f111bc 100644 --- a/apps/game/src/data/quests.ts +++ b/apps/game/src/data/quests.ts @@ -12,9 +12,6 @@ /** Квест «Три цветка» (docs/world.md, акт 1): собрать и посадить колокольчики. */ export const QUEST_FLOWERS = 3; -/** Побочный эффект сцены после диалога стадии. */ -export type QuestEffect = 'plant_flowers' | 'give_cloth'; - /** Одна стадия квеста. */ export interface QuestStage { /** Чей диалог ведёт стадию (id из NpcDef). */ @@ -30,8 +27,6 @@ ready?: (state: GameState) => boolean; /** Флаг, которым граф диалога завершает стадию. Без него стадия — «эпилог». */ doneFlag?: FlagId; - /** Побочный эффект сцены после диалога стадии (см. applyQuestEffect). */ - effect?: QuestEffect; } export interface QuestDef { @@ -61,8 +56,7 @@ goal: QUEST_FLOWERS, dialogue: 'elder_hand_in', ready: (state) => state.getNumber(VARS.flowers) >= QUEST_FLOWERS, - doneFlag: FLAGS.quest_bells_done, - effect: 'plant_flowers' + doneFlag: FLAGS.quest_bells_done }, // Эпилог: крючок акта 1 — разговор с Милой после посадки. { @@ -108,21 +102,6 @@ return null; } -/** Побочные эффекты внеквестовых диалогов (знакомства). */ -const EXTRA_EFFECTS: Record = { - trader_first: 'give_cloth' // Мила дарит полотно при знакомстве -}; - -/** Побочный эффект по завершённому диалогу (из стадий реестра и знакомств). */ -export function questEffectFor(dialogueId: string): QuestEffect | null { - for (const quest of QUESTS) { - for (const stage of quest.stages) { - if (stage.dialogue === dialogueId && stage.effect) return stage.effect; - } - } - return EXTRA_EFFECTS[dialogueId] ?? null; -} - /** Одна строка журнала квестов. */ export interface QuestEntry { done: boolean; diff --git a/apps/game/src/data/validate.ts b/apps/game/src/data/validate.ts index 5e6548c..3df0df3 100644 --- a/apps/game/src/data/validate.ts +++ b/apps/game/src/data/validate.ts @@ -13,6 +13,8 @@ import { ENEMY_KINDS } from './enemies'; import { QUESTS } from './quests'; import { FLAGS, VARS } from './ids'; +import { ITEMS } from './items'; +import { DIALOGUE_CUSTOM } from './effects'; /** * Runtime-валидация контента → инварианты (замена JSON Schema: истина одна — @@ -158,6 +160,17 @@ for (const f of n.when ?? []) checkFlag(f, where, `when`); for (const f of n.whenNot ?? []) checkFlag(f, where, `whenNot`); if (n.whenVar) checkVar(n.whenVar.key, where, 'whenVar.key'); + for (const op of n.do ?? []) { + if (op.kind === 'giveItem' || op.kind === 'takeItem') { + if (op.id === undefined || !(op.id in ITEMS)) { + out.push({ id: 'do-item-unknown', severity: 'error', message: `do[].${op.kind}: предмет «${op.id}» вне реестра ITEMS`, where }); + } + } else if (op.kind === 'custom') { + if (op.id === undefined || !(op.id in DIALOGUE_CUSTOM)) { + out.push({ id: 'do-custom-unknown', severity: 'error', message: `do[].custom: имя «${op.id}» вне реестра DIALOGUE_CUSTOM`, where }); + } + } + } } } } diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index cc89724..e0c42ca 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -50,7 +50,8 @@ import { SceneAgentView } from '../agent/SceneAgentView'; import type { NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; -import { QUEST_FLOWERS, questDialogueFor, questEffectFor } from '../data/quests'; +import { QUEST_FLOWERS, questDialogueFor } from '../data/quests'; +import { DIALOGUE_CUSTOM, type DialogueCustomId } from '../data/effects'; import { TENSION_STEM } from '../data/music'; import { FLAGS, VARS } from '../data/ids'; import type { EnemyKindId } from '../data/enemies'; @@ -75,6 +76,8 @@ private map: IsometricTileMap; private player: PlayerController; private dialogue: DialogueSystem; + /** Сюжетные custom-эффекты, отложенные до конца диалога. */ + private pendingCustom = new Set(); private npcs: { def: NpcDef; view: Container; body: Sprite }[] = []; private actors = new IsoDepthLayer(); private hint: Container; @@ -322,8 +325,18 @@ typewriter: 40, moodColors: { sad: 0x9aaad8, angry: 0xd89a9a, warm: 0xd8c79a } }); - this.dialogue.onDialogueFinished = (id) => this.onDialogueFinished(id); + this.dialogue.onDialogueFinished = () => this.onDialogueFinished(); this.dialogue.onLineShown = () => void this.game.audio.play('sfx/chime', 0.5); + this.dialogue.registerSink({ + giveItem: (id, count) => this.game.inventory.add(id, count), + takeItem: (id, count) => this.game.inventory.remove(id, count), + playSound: (key) => void this.game.audio.play(key, 0.8), + showToast: (text) => this.showToast(text), + custom: (name) => { + // Сюжетные эффекты — по завершении диалога (кат-сцена не рвёт реплику). + this.pendingCustom.add(name); + } + }); // Маршрутизация клика/переходов/отложенных взаимодействий. this.router = new InteractionRouter({ @@ -933,11 +946,12 @@ this.dialogue.start(DIALOGUES[id], id); } - private onDialogueFinished(id: string): void { - // Побочные эффекты — из квест-стадий реестра. - const effect = questEffectFor(id); - if (effect === 'plant_flowers') this.startHandInCutscene(); - if (effect === 'give_cloth') this.game.inventory.add('cloth'); + private onDialogueFinished(): void { + // Сюжетные custom-эффекты графа — только когда реплики доскажены. + if (this.pendingCustom.has(DIALOGUE_CUSTOM.plant_flowers)) { + this.pendingCustom.delete(DIALOGUE_CUSTOM.plant_flowers); + this.startHandInCutscene(); + } } /** Посадка цветов у тропы: поляна гудит колокольчиками и разрастается. */ diff --git a/apps/game/src/systems/DialogueEffects.ts b/apps/game/src/systems/DialogueEffects.ts new file mode 100644 index 0000000..8042470 --- /dev/null +++ b/apps/game/src/systems/DialogueEffects.ts @@ -0,0 +1,42 @@ +import type { DialogueEffectOp } from '@rpg/engine'; +import type { ItemId } from '../data/items'; +import type { DialogueCustomId } from '../data/effects'; + +/** + * Приёмник диалоговых эффектов: игра реализует его (сцена/системы), + * движок только эмитит операции из do[]. Сюда стекается giveItem/takeItem, + * звук, всплывашка и сюжетные custom-эффекты. + */ +export interface DialogueEffectSink { + giveItem(id: ItemId, count: number): void; + takeItem(id: ItemId, count: number): void; + playSound(key: string): void; + showToast(text: string): void; + custom(name: DialogueCustomId, payload: Record | undefined): void; +} + +/** + * Применить одну операцию эффекта к приёмнику. Неизвестный kind — ошибка + * валидатора контента, рантайм его молча игнорирует. + */ +export function applyDialogueEffect(op: DialogueEffectOp, sink: DialogueEffectSink): void { + switch (op.kind) { + case 'giveItem': + if (op.id) sink.giveItem(op.id as ItemId, op.count ?? 1); + return; + case 'takeItem': + if (op.id) sink.takeItem(op.id as ItemId, op.count ?? 1); + return; + case 'sound': + if (op.id) sink.playSound(op.id); + return; + case 'toast': + if (op.text) sink.showToast(op.text); + return; + case 'custom': + sink.custom((op.id ?? '') as DialogueCustomId, op.payload); + return; + default: + return; + } +} \ No newline at end of file diff --git a/apps/game/src/systems/DialogueSystem.ts b/apps/game/src/systems/DialogueSystem.ts index fa01a53..eb0f9ea 100644 --- a/apps/game/src/systems/DialogueSystem.ts +++ b/apps/game/src/systems/DialogueSystem.ts @@ -6,14 +6,17 @@ type DialogueBoxOptions, type DialogueGraph } from '@rpg/engine'; +import { applyDialogueEffect, type DialogueEffectSink } from './DialogueEffects'; /** * Диалоги игры: DialogueRunner движка ходит по графам и применяет флаги - * к GameState, DialogueBox рисует реплики и варианты. + * к GameState, DialogueBox рисует реплики и варианты, do[]-эффекты идут + * в зарегистрированный EffectSink. */ export class DialogueSystem { private box: DialogueBox; private runner: DialogueRunner; + private sink: DialogueEffectSink | null = null; /** Сюжетное событие «диалог завершён» — для триггеров/квестов. */ onDialogueFinished: ((id: string) => void) | null = null; @@ -31,12 +34,20 @@ this.runner = new DialogueRunner(state); this.runner.setView({ - show: ({ speaker, text, choices }) => { - this.box.show({ speaker, text, choices }); + show: ({ speaker, text, mood, choices }) => { + this.box.show({ speaker, text, mood, choices }); this.onLineShown?.(); }, hide: () => this.box.hide() }); + this.runner.onEffect = (op) => { + if (this.sink) applyDialogueEffect(op, this.sink); + }; + } + + /** Зарегистрировать приёмник do[]-эффектов (сцена с геймплеем). */ + registerSink(sink: DialogueEffectSink): void { + this.sink = sink; } get active(): boolean { diff --git a/apps/game/tools/checks/interact-world.mjs b/apps/game/tools/checks/interact-world.mjs index 33abbf5..afec757 100644 --- a/apps/game/tools/checks/interact-world.mjs +++ b/apps/game/tools/checks/interact-world.mjs @@ -104,7 +104,8 @@ await ctx.agent.tapTile(17, 11); const o = await ctx.agent.waitFor('s.dialogue != null', { timeoutTicks: 900 }); c.expect(o.ok, 'повторный диалог не открылся'); - await ctx.agent.press('advance'); + await ctx.agent.press('advance'); // догнать печать (typewriter) + await ctx.agent.press('advance'); // следующая реплика const w = await ctx.agent.waitFor( 's.dialogue != null && (s.dialogue.text ?? "").includes("Моты")', { timeoutTicks: 300 }