diff --git a/apps/game/src/data/__tests__/quests.test.ts b/apps/game/src/data/__tests__/quests.test.ts index 95e7360..703eaa3 100644 --- a/apps/game/src/data/__tests__/quests.test.ts +++ b/apps/game/src/data/__tests__/quests.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { GameState, Inventory } from '@rpg/engine'; -import { QUESTS, QUEST_FLOWERS, activeStage, questDialogueFor, questLog } from '../quests'; +import { QUESTS, QUEST_FLOWERS, activeStage, questDialogueFor, questLog, questTracker } from '../quests'; import { applyDialogueEffect, type DialogueEffectSink } from '../../systems/DialogueEffects'; import { DIALOGUE_CUSTOM } from '../effects'; @@ -18,7 +18,8 @@ expect(activeStage(s, bells)?.dialogue).toBe('elder_hand_in'); expect(questDialogueFor(s, 'elder')).toBeNull(); // цветов мало expect(questLog(s)).toEqual([ - { done: false, text: `Собрать лунные колокольчики у Серых прудов (0/${QUEST_FLOWERS})` } + { done: true, text: 'Пепел дышит у прудов. Поговорить с Ирвином' }, + { done: false, text: `Собрать три колокольчика — Серые пруды, северо-запад лугов (0/${QUEST_FLOWERS})` } ]); }); @@ -28,11 +29,12 @@ s.setVar('flowers', QUEST_FLOWERS); expect(questDialogueFor(s, 'elder')).toBe('elder_hand_in'); expect(questLog(s)).toEqual([ - { done: false, text: `Собрать лунные колокольчики у Серых прудов (3/3)` } + { done: true, text: 'Пепел дышит у прудов. Поговорить с Ирвином' }, + { done: false, text: `Собрать три колокольчика — Серые пруды, северо-запад лугов (3/3)` } ]); }); - it('после сдачи — эпилог с Милой и финальная строка', () => { + it('после сдачи — эпилог с Милой, до разговора с ней активен', () => { const s = new GameState(); s.setFlag('quest_bells_taken'); s.setVar('flowers', QUEST_FLOWERS); @@ -40,9 +42,35 @@ expect(questDialogueFor(s, 'trader')).toBe('trader_after'); expect(questDialogueFor(s, 'elder')).toBeNull(); // Ирвин — обычный repeat expect(questLog(s)).toEqual([ - { done: true, text: `Собрать лунные колокольчики у Серых прудов (3/3)` }, - { done: true, text: 'Цветы посажены у тропы. Поляна гудит.' } + { done: true, text: 'Пепел дышит у прудов. Поговорить с Ирвином' }, + { done: true, text: `Собрать три колокольчика — Серые пруды, северо-запад лугов (3/3)` }, + { done: false, text: 'Поговорить с Милой — поляна у тропы гудит' } ]); + // Разговор с Милой (act2_hook) завершает и эпилог. + s.setFlag('act2_hook'); + expect(questLog(s).every((e) => e.done)).toBe(true); + }); +}); + +describe('трекер HUD (questTracker)', () => { + it('в свежем состоянии — цель взятия (поговорить с Ирвином)', () => { + const s = new GameState(); + expect(questTracker(s)).toBe('Пепел дышит у прудов. Поговорить с Ирвином'); + }); + + it('на стадии сбора показывает прогресс', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + s.setVar('flowers', 1); + expect(questTracker(s)).toBe(`Собрать три колокольчика — Серые пруды, северо-запад лугов (1/3)`); + }); + + it('после завершения квеста колокольчиков — цель следующего квеста', () => { + const s = new GameState(); + s.setFlag('quest_bells_taken'); + s.setFlag('quest_bells_done'); + s.setFlag('act2_hook'); + expect(questTracker(s)).toBe('Резонатор Клинта молчит. Поговорить с Клинтом'); }); }); diff --git a/apps/game/src/data/dialogues/clint_first.json b/apps/game/src/data/dialogues/clint_first.json index f30a8fb..0179533 100644 --- a/apps/game/src/data/dialogues/clint_first.json +++ b/apps/game/src/data/dialogues/clint_first.json @@ -24,17 +24,13 @@ }, "offer": { "speaker": "Клинт", - "text": "Деревья в роще — как механизмы, кабелями оплетены. Прозвени их: кто отзовётся — тот и тон даст.", + "text": "Деревья в Ржавой роще — за восточной околицей, — как механизмы, кабелями оплетены. Прозвени их: кто отзовётся — тот и тон даст.", "setFlags": ["quest_resonator_taken"], "next": "reply" }, "reply": { "speaker": "Звонарь", "text": "*кивает*" - }, - "reply": { - "speaker": "Звонарь", - "text": "*кивает*" } } } \ No newline at end of file diff --git a/apps/game/src/data/dialogues/clint_repeat.json b/apps/game/src/data/dialogues/clint_repeat.json index da52a55..c2694d8 100644 --- a/apps/game/src/data/dialogues/clint_repeat.json +++ b/apps/game/src/data/dialogues/clint_repeat.json @@ -4,6 +4,7 @@ "grumble": { "speaker": "Клинт", "text": "Опять ты. Хорошо. Слушай: у Машины есть голос — низкий, через пепел. Все её боятся, а зря.", + "setFlags": ["machine_hook"], "next": "hint" }, "hint": { diff --git a/apps/game/src/data/dialogues/elder_first.json b/apps/game/src/data/dialogues/elder_first.json index fe6e0e1..1b32b10 100644 --- a/apps/game/src/data/dialogues/elder_first.json +++ b/apps/game/src/data/dialogues/elder_first.json @@ -8,7 +8,7 @@ }, "ask": { "speaker": "Старейшина Ирвин", - "text": "Три поляны лунных колокольчиков там, у Серых прудов. Если не собрать цветы до выдоха — задохнутся.", + "text": "Три лунных колокольчика цветут у Серых прудов — за северо-западным краем лугов, у старого колодца. Если не собрать до выдоха — задохнутся.", "next": "task" }, "task": { diff --git a/apps/game/src/data/dialogues/elder_hand_in.json b/apps/game/src/data/dialogues/elder_hand_in.json index afe62f1..8b5a334 100644 --- a/apps/game/src/data/dialogues/elder_hand_in.json +++ b/apps/game/src/data/dialogues/elder_hand_in.json @@ -3,7 +3,7 @@ "nodes": { "count": { "speaker": "Старейшина Ирвин", - "text": "Три цветка. Живые. Сажай у тропы, звонарь. Серая земля примет.", + "text": "Три цветка. Живые. Сажай у тропы здесь, в Звенце. Серая земля примет.", "next": "accept" }, "accept": { diff --git a/apps/game/src/data/ids.ts b/apps/game/src/data/ids.ts index 3b18463..5ce34dd 100644 --- a/apps/game/src/data/ids.ts +++ b/apps/game/src/data/ids.ts @@ -36,6 +36,8 @@ quest_resonator_taken: 'quest_resonator_taken', /** Резонатор настроен (диалог clint_tune; doneFlag стадии 2). */ quest_resonator_done: 'quest_resonator_done', + /** Клинт указал на Машину (clint_repeat; doneFlag эпилога резонатора). */ + machine_hook: 'machine_hook', /** Дерево-механизм №1 прозвенело (интерактив tone_tree_1). */ tone_tree_1: 'tone_tree_1', /** Дерево-механизм №2 прозвенело (интерактив tone_tree_2). */ diff --git a/apps/game/src/data/interactables.ts b/apps/game/src/data/interactables.ts index 994cf77..e4527a2 100644 --- a/apps/game/src/data/interactables.ts +++ b/apps/game/src/data/interactables.ts @@ -2,7 +2,7 @@ import type { ItemId } from './items'; import type { AreaId } from './locations'; import { FLAGS, VARS } from './ids'; -import { QUEST_TONE_TILES } from './quests'; +import { QUEST_TONES, QUEST_TONE_TILES } from './quests'; /** * Интерактивные объекты области: сундуки, очаги, записки, прилавки, столбы @@ -36,6 +36,8 @@ count?: number; /** Ключ звука (sfx/...). */ sound?: string; + /** Тост вместо text (после эффектов); с varKey — дописывает « (N/goal)». */ + toast?: { text: string; varKey?: string; goal?: number }; /** Сюжетный эффект (DIALOGUE_CUSTOM): сцена запускает катсцену/событие. */ custom?: string; } @@ -205,6 +207,7 @@ { when: { notFlag: FLAGS[id] }, text: 'Звон колокольчика — и кабели отвечают: дерево отдаёт свой тон.', + toast: { text: 'Тон', varKey: VARS.tones, goal: QUEST_TONES }, setFlags: [FLAGS[id]], addVar: { id: VARS.tones, by: 1 }, sound: 'sfx/bell_hit' diff --git a/apps/game/src/data/quests.ts b/apps/game/src/data/quests.ts index a58ebaa..e9b24cc 100644 --- a/apps/game/src/data/quests.ts +++ b/apps/game/src/data/quests.ts @@ -72,14 +72,14 @@ // Взятие: Ирвин рассказывает про поляны у прудов. { npc: 'elder', - text: 'Слышать гул на закате — пепел дышит у прудов.', + text: 'Пепел дышит у прудов. Поговорить с Ирвином', dialogue: 'elder_first', doneFlag: FLAGS.quest_bells_taken }, // Сбор: активная строка журнала с прогрессом; сдача — Ирвину. { npc: 'elder', - text: 'Собрать лунные колокольчики у Серых прудов', + text: 'Собрать три колокольчика — Серые пруды, северо-запад лугов', progressKey: VARS.flowers, goal: QUEST_FLOWERS, dialogue: 'elder_hand_in', @@ -89,8 +89,9 @@ // Эпилог: крючок акта 1 — разговор с Милой после посадки. { npc: 'trader', - text: 'Цветы посажены у тропы. Поляна гудит.', - dialogue: 'trader_after' + text: 'Поговорить с Милой — поляна у тропы гудит', + dialogue: 'trader_after', + doneFlag: FLAGS.act2_hook } ] }, @@ -102,14 +103,14 @@ // Взятие: ворчливое знакомство — тот же граф ставит met_clint. { npc: 'clint', - text: 'Резонатор Клинта молчит третий месяц', + text: 'Резонатор Клинта молчит. Поговорить с Клинтом', dialogue: 'clint_first', doneFlag: FLAGS.quest_resonator_taken }, // Сбор тонов: деревья-механизмы рощи (интерактивы), сдача Клинту. { npc: 'clint', - text: 'Прозвенеть у деревьев-механизмов Ржавой рощи', + text: 'Прозвенеть у деревьев-механизмов — Ржавая роща, восток Звенца', progressKey: VARS.tones, goal: QUEST_TONES, dialogue: 'clint_tune', @@ -119,8 +120,9 @@ // Эпилог: подсказка о Машине — мост к финалу акта. { npc: 'clint', - text: 'Резонатор держит тон. Клинт ворчит довольный.', - dialogue: 'clint_repeat' + text: 'Зайти к Клинту — резонатор держит тон', + dialogue: 'clint_repeat', + doneFlag: FLAGS.machine_hook } ] } @@ -155,15 +157,18 @@ text: string; } -/** Журнал: стадии квестов с момента взятия (первая служебная стадия не показывается). */ +/** Журнал: достигнутые стадии квестов. Взятия-стадия видна после принятия — + * журнал не «знает» о квесте раньше героя. */ export function questLog(state: GameState): QuestEntry[] { const entries: QuestEntry[] = []; for (const quest of QUESTS) { let reached = true; quest.stages.forEach((stage, i) => { - // Эпилог (без doneFlag) достигнут — значит, завершён. const done = stage.doneFlag === undefined || state.hasFlag(stage.doneFlag); - if (i > 0 && reached) { + // Взятия-стадия (первая) видна, только когда принята (done); + // остальные достигнутые стадии — всегда. + const show = i > 0 || done; + if (show && reached) { const progress = stage.progressKey !== undefined && stage.goal !== undefined ? ` (${Math.min(state.getNumber(stage.progressKey), stage.goal)}/${stage.goal})` @@ -174,4 +179,18 @@ }); } return entries; +} + +/** Трекер HUD: текст первой активной стадии с прогрессом (null — активных нет). */ +export function questTracker(state: GameState): string | null { + for (const quest of QUESTS) { + const stage = activeStage(state, quest); + if (!stage) continue; + const progress = + stage.progressKey !== undefined && stage.goal !== undefined + ? ` (${Math.min(state.getNumber(stage.progressKey), stage.goal)}/${stage.goal})` + : ''; + return `${stage.text}${progress}`; + } + return null; } \ No newline at end of file diff --git a/apps/game/src/scenes/InventoryScene.ts b/apps/game/src/scenes/InventoryScene.ts index c413ba3..3965077 100644 --- a/apps/game/src/scenes/InventoryScene.ts +++ b/apps/game/src/scenes/InventoryScene.ts @@ -22,12 +22,12 @@ } protected build(): void { - const panel = new Panel({ width: 280, height: 196 }); - panel.position.set((Game.VIRTUAL_W - 280) / 2, (Game.VIRTUAL_H - 196) / 2); + const panel = new Panel({ width: 300, height: 222 }); + panel.position.set((Game.VIRTUAL_W - 300) / 2, (Game.VIRTUAL_H - 222) / 2); const title = new PixelText({ text: 'ЗВОНАРЬ', size: 14, color: 0xd8c79a }); title.anchor.set(0.5); - title.position.set(140, 14); + title.position.set(150, 14); panel.addChild(title); const addLine = (text: string, color: number, x: number, y: number): void => { @@ -41,11 +41,11 @@ result.position.set(16, 28); panel.addChild(result); - // Сумка: список с курсором и скроллом (6 строк), Enter — применить. + // Сумка: список с курсором и скроллом (5 строк), Enter — применить. addLine('Сумка', 0x999988, 16, 40); const slots = this.game.inventory.all; if (slots.length > 0) { - this.menu = new MenuList({ width: 240, height: 14, gap: 1, size: 10, visibleRows: 6 }); + this.menu = new MenuList({ width: 260, height: 14, gap: 1, size: 10, visibleRows: 5 }); this.menu.position.set(20, 52); this.menu.setItems( slots.map((slot) => ({ @@ -58,14 +58,25 @@ addLine('— пусто —', 0x777788, 24, 54); } - // Журнал квестов — фиксированно ниже сумки, без фокуса. - addLine('Журнал', 0x999988, 16, 144); - let y = 158; + // Журнал квестов — текущие задачи (незавершённые достигнутые стадии), + // без фокуса; перенос по ширине панели, шаг — по фактической высоте. + addLine('Журнал', 0x999988, 16, 132); + let y = 146; + let shown = 0; for (const entry of questLog(this.game.state)) { - const mark = entry.done ? 'x' : '·'; - addLine(`[${mark}] ${entry.text}`, entry.done ? 0xd8c79a : 0xaaaaaa, 24, y); - y += 12; + if (entry.done) continue; + const line = new PixelText({ + text: `· ${entry.text}`, + size: 10, + color: 0xaaaaaa, + wordWrapWidth: 264 + }); + line.position.set(24, y); + panel.addChild(line); + y += Math.ceil(line.height) + 3; + shown++; } + if (shown === 0) addLine('— текущих задач нет —', 0x777788, 24, 146); this.view.addChild(panel); this.game.renderer.uiRoot.addChild(this.view); diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 5ea2f0b..c829d6c 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -39,7 +39,7 @@ import { npcsForArea } from '../data/npcs'; import { ActorWalker } from '../systems/cutscene/CutsceneActors'; import { DIALOGUES } from '../data/dialogues'; -import { questDialogueFor } from '../data/quests'; +import { questDialogueFor, questTracker } from '../data/quests'; import { DIALOGUE_CUSTOM, type DialogueCustomId } from '../data/effects'; import type { ItemId } from '../data/items'; import { FLAGS, VARS } from '../data/ids'; @@ -601,6 +601,7 @@ } const tile = this.player.currentTile(); this.hud.setMotes(this.game.state.getNumber(VARS.motes)); + this.hud.setQuest(questTracker(this.game.state)); this.atmosphere.updateHazard(tile); this.actors.setDepth(this.player.view, tile.x, tile.y); this.updateCameraFollow(); diff --git a/apps/game/src/systems/Interactables.ts b/apps/game/src/systems/Interactables.ts index b22e9ae..9f0adc5 100644 --- a/apps/game/src/systems/Interactables.ts +++ b/apps/game/src/systems/Interactables.ts @@ -72,6 +72,14 @@ if (def.once) this.state.setFlag(usedFlag(def.id)); if (r.sound) this.sink.playSound(r.sound); if (r.custom) this.sink.onCustom?.(r.custom); - this.sink.showToast(r.text); + if (r.toast) { + // Тост с прогрессом (как у цветов): «Тон (2/3)». + const n = r.toast.varKey !== undefined ? this.state.getNumber(r.toast.varKey) : null; + const progress = + n !== null && r.toast.goal !== undefined ? ` (${Math.min(n, r.toast.goal)}/${r.toast.goal})` : ''; + this.sink.showToast(r.toast.text + progress); + } else { + this.sink.showToast(r.text); + } } } \ No newline at end of file diff --git a/apps/game/src/systems/LocationHud.ts b/apps/game/src/systems/LocationHud.ts index e24233c..c092c36 100644 --- a/apps/game/src/systems/LocationHud.ts +++ b/apps/game/src/systems/LocationHud.ts @@ -2,13 +2,16 @@ /** * HUD локации: название области (правый верх), подсказка управления (левый - * верх), счётчик мотов (под названием). Одна вьюха в uiRoot — сцена только - * добавляет её, тикает setMotes и гасит в exit. + * верх), счётчик мотов (под названием), трекер цели квеста (под подсказкой). + * Одна вьюха в uiRoot — сцена только добавляет её, тикает setMotes/setQuest + * и гасит в exit. */ export class LocationHud { readonly view = new Container(); private motesLabel: PixelText; + private questLabel: PixelText; private lastMotes = -1; + private lastQuest: string | null = null; constructor(areaName: string) { const label = new PixelText({ text: areaName, size: 11, color: 0x999988 }); @@ -27,6 +30,9 @@ // Под сердечками (HealthBar занимает y≈13..18). hint.position.set(6, 23); this.view.addChild(hint); + this.questLabel = new PixelText({ text: '', size: 10, color: 0xd8c79a }); + this.questLabel.position.set(6, 35); // под подсказкой управления + this.view.addChild(this.questLabel); } /** Счётчик мотов: текст пишем только при изменении (сбор — редкое событие). */ @@ -36,6 +42,13 @@ this.motesLabel.text = count > 0 ? `Пепельные моты: ${count}` : ''; } + /** Трекер цели: «Цель: <текст стадии>»; null — активных квестов нет. */ + setQuest(text: string | null): void { + if (text === this.lastQuest) return; + this.lastQuest = text; + this.questLabel.text = text === null ? '' : `Цель: ${text}`; + } + destroy(): void { this.view.destroy({ children: true }); } diff --git a/apps/game/src/systems/__tests__/Interactables.test.ts b/apps/game/src/systems/__tests__/Interactables.test.ts index c513b0e..b043c56 100644 --- a/apps/game/src/systems/__tests__/Interactables.test.ts +++ b/apps/game/src/systems/__tests__/Interactables.test.ts @@ -111,6 +111,26 @@ expect(calls.toast).toEqual(['мот']); }); + it('toast с varKey: тост прогресса вместо text, после addVar', () => { + const { world, calls, st } = make([ + def({ + id: 'tone_tree_1', + kind: 'examine', + once: false, + responses: [ + { + text: 'Дерево отдаёт свой тон.', + toast: { text: 'Тон', varKey: 'tones', goal: 3 }, + addVar: { id: 'tones', by: 1 } + } + ] + }) + ]); + world.tryInteract(world.defAt(3, 3)!); + expect(st.getNumber('tones')).toBe(1); + expect(calls.toast).toEqual(['Тон (1/3)']); + }); + it('when от GameState: реакция меняется с флагом', () => { const { world, calls, st } = make([ def({ diff --git a/apps/game/src/systems/combat/CombatWorld.ts b/apps/game/src/systems/combat/CombatWorld.ts index c8d5832..9bd9362 100644 --- a/apps/game/src/systems/combat/CombatWorld.ts +++ b/apps/game/src/systems/combat/CombatWorld.ts @@ -9,6 +9,7 @@ inConeW, tileToWorld, moveCircle, + circleFits, separateCircles, type Entity, type SceneRegistry, @@ -139,6 +140,10 @@ hpFraction: en.hp / en.kind.hp }); + // Позиция до шага и толкучки: если сепарация втолкнет тело в стену + // (separateCircles стены не проверяет), откатываемся сюда. + const beforeCrowd = { ...en.pos }; + switch (intent.type) { case 'move': stepWithCollision( @@ -177,6 +182,11 @@ separateCircles(en.pos, en.kind.radius, hit.pos, hit.radius); } } + // Стена сильнее толкучки: втолкнули в непроходимый тайл — откат. + if (!circleFits(this.combat.deps.map, en.pos, en.kind.radius)) { + en.pos.x = beforeCrowd.x; + en.pos.y = beforeCrowd.y; + } } } }