From 451c6ea025f00351348df95b05e3a0f08f20c32d Mon Sep 17 00:00:00 2001 From: cocos02 Date: Tue, 9 Jun 2026 17:21:54 +0800 Subject: [PATCH] chore: sync latest changes --- ROADMAP.md | 46 ++++---- engine/core/Engine.ts | 7 ++ engine/core/StateManager.ts | 2 + engine/systems/AchievementSystem.ts | 81 +++++++++++++++ engine/systems/SaveSystem.ts | 20 +++- engine/types.ts | 11 ++ public/scenes/demo.json | 36 ++++++- src/App.vue | 20 ++++ src/components/AchievementPanel.vue | 156 ++++++++++++++++++++++++++++ src/components/AchievementToast.vue | 108 +++++++++++++++++++ src/composables/useGameEngine.ts | 11 ++ src/stores/gameStore.ts | 33 +++++- 12 files changed, 503 insertions(+), 28 deletions(-) create mode 100644 engine/systems/AchievementSystem.ts create mode 100644 src/components/AchievementPanel.vue create mode 100644 src/components/AchievementToast.vue diff --git a/ROADMAP.md b/ROADMAP.md index 5f881a3..1731c86 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -739,9 +739,18 @@ engine.on('choiceRequest', (choiceList) => { - [x] `public/scenes/demo.json` — left_door + trust_ending 各一个 prompt 示例 - [x] 验证:TypeScript + Vite build 通过 -### P14 成就系统 — 事件触发 + 条件检测 + Toast 弹窗(待实现) +### P14 成就系统 — 纯变量检测 + 单一检查点 + Toast 队列 ✅ 已完成 2026-06-09 -目标:Steam 式成就系统,驱动重玩探索。事件触发或变量条件检测,解锁时底部 toast 滑入。 +目标:Steam 式成就系统,驱动重玩探索。所有成就通过变量检测,在 `StateManager.apply` 末尾单一检查点触发。 + +**设计决策(对标 Detroit / Dark Pictures):** + +| 决策 | 做法 | +|------|------| +| **检测方式** | **纯变量 + 单一检查点** — `condition: { variable, op, value }`,`StateManager.apply` 末尾 `achievementSystem.check(variables)` | +| **Toast 弹出** | **逐个队列** — 同时解锁多个成就时一个消失后下一个才弹出 | +| **图标** | **可选 URL** — 有 `icon` 路径则显示缩略图,为空则不显示图标栏 | +| **入口** | **仅主菜单** — 不在游戏内 Esc 菜单,属于元游戏层 | **数据结构:** @@ -754,35 +763,26 @@ engine.on('choiceRequest', (choiceList) => { "description": "成功完成一次 QTE", "icon": "", "hidden": false, - "trigger": { "event": "qteResult", "success": true } - }, - { - "id": "explorer", - "title": "探索者", - "description": "搜索过房间的每一个角落", - "icon": "", - "hidden": false, - "trigger": { "variable": "investigation", "op": ">=", "value": 2 } + "condition": { "variable": "qte_succeeded", "op": ">=", "value": 1 } } ] } ``` -触发条件支持:`{ event }`(qteResult / choiceMade / gameEnd / sceneReached)或 `{ variable, op, value }`。 -解锁持久化到 IndexedDB,toast 滑入 3 秒消失。 +QTE 成功 / 到达隐藏结局 / 通关等"事件型"成就,通过在对应 effects 或 onEnter 中变量来驱动检测。 **实现清单:** -- [ ] `engine/types.ts` — `GameData.achievements`、`AchievementDef` 接口 -- [ ] `engine/systems/AchievementSystem.ts` — 触发检测 + 解锁 + 回调 onUnlock -- [ ] `engine/systems/SaveSystem.ts` — DB v5 新增 `achievements` 表 -- [ ] `engine/core/Engine.ts` — 事件点调用 `achievementSystem.check(event, data)` -- [ ] `src/components/AchievementToast.vue` — 底部弹窗滑入/滑出动画 -- [ ] `src/components/AchievementPanel.vue` — 成就列表(全部/已解锁/未解锁) -- [ ] `src/stores/gameStore.ts` — 成就解锁状态 -- [ ] `src/App.vue` — 整合 AchievementToast + 主菜单"成就"入口 -- [ ] `public/scenes/demo.json` — 2~3 个成就示例 -- [ ] 验证:QTE 成功触发 toast、变量成就自动检测、面板显示正确 +- [x] `engine/types.ts` — `GameData.achievements`、`AchievementDef { id, title, description, icon?, hidden, condition }` +- [x] `engine/systems/AchievementSystem.ts` — `check(variables)` 遍历未解锁成就;`onUnlock` 回调;toast 队列管理 +- [x] `engine/systems/SaveSystem.ts` — DB v5 新增 `achievements` 表 +- [x] `engine/core/StateManager.ts` — `apply` 末尾 `onAfterApply` 回调 +- [x] `engine/core/Engine.ts` — `stateManager.onAfterApply → achievementSystem.check` +- [x] `src/components/AchievementToast.vue` — 底部弹窗滑入/滑出动画 +- [x] `src/components/AchievementPanel.vue` — 成就列表(全部/已解锁/未解锁/隐藏) +- [x] `src/stores/gameStore.ts` — 成就定义/解锁/toast 状态 +- [x] `src/App.vue` — 整合 AchievementToast + 主菜单"成就"入口 +- [x] `public/scenes/demo.json` — 3 个成就 + QTE success 变量 set + alone_ending onEnter ### P15 结局画廊 + 章节回顾 — 分支图可视化(待实现) diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index 4b14f70..36d9cdd 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -5,6 +5,7 @@ import { StateManager } from './StateManager' import { ChoiceSystem } from '../systems/ChoiceSystem' import { QTESystem } from '../systems/QTESystem' import { AudioSystem } from '../systems/AudioSystem' +import { AchievementSystem } from '../systems/AchievementSystem' type EventHandler = (...args: any[]) => void @@ -15,6 +16,7 @@ export class Engine { choiceSystem: ChoiceSystem qteSystem: QTESystem audioSystem: AudioSystem + achievementSystem: AchievementSystem private currentScene: SceneNode | null = null private events: Map> = new Map() @@ -42,6 +44,11 @@ export class Engine { this.choiceSystem = new ChoiceSystem() this.qteSystem = new QTESystem() this.audioSystem = new AudioSystem() + this.achievementSystem = new AchievementSystem() + + this.stateManager.onAfterApply = (vars) => { + this.achievementSystem.check(vars) + } this.videoManager.onTimeUpdate(this.onTimeUpdate) } diff --git a/engine/core/StateManager.ts b/engine/core/StateManager.ts index 3d622bb..a3bb674 100644 --- a/engine/core/StateManager.ts +++ b/engine/core/StateManager.ts @@ -4,6 +4,7 @@ export class StateManager { variables: Record = {} flags: Set = new Set() history: ChoiceRecord[] = [] + onAfterApply: ((variables: Record) => void) | null = null init(initialVars: Record) { this.variables = { ...initialVars } @@ -72,6 +73,7 @@ export class StateManager { break } } + this.onAfterApply?.(this.variables) } recordChoice(choice: ChoiceRecord) { diff --git a/engine/systems/AchievementSystem.ts b/engine/systems/AchievementSystem.ts new file mode 100644 index 0000000..9b22d81 --- /dev/null +++ b/engine/systems/AchievementSystem.ts @@ -0,0 +1,81 @@ +import type { AchievementDef } from '../types' + +type UnlockCallback = (achievement: AchievementDef) => void + +export class AchievementSystem { + private definitions: AchievementDef[] = [] + private unlockedIds: Set = new Set() + private onUnlock: UnlockCallback | null = null + private toastQueue: string[] = [] + private toastActive = false + + init(defs: AchievementDef[], alreadyUnlocked: string[]) { + this.definitions = defs + this.unlockedIds = new Set(alreadyUnlocked) + this.toastQueue = [] + this.toastActive = false + } + + setUnlockCallback(cb: UnlockCallback) { + this.onUnlock = cb + } + + check(variables: Record) { + for (const def of this.definitions) { + if (this.unlockedIds.has(def.id)) continue + + const cond = def.condition + const val = variables[cond.variable] ?? 0 + let matched = false + + switch (cond.op) { + case '==': matched = val === cond.value; break + case '!=': matched = val !== cond.value; break + case '>': matched = val > (cond.value as number); break + case '<': matched = val < (cond.value as number); break + case '>=': matched = val >= (cond.value as number); break + case '<=': matched = val <= (cond.value as number); break + } + + if (matched) { + this.unlockedIds.add(def.id) + this.onUnlock?.(def) + this.enqueueToast(def.id) + } + } + } + + private enqueueToast(id: string) { + this.toastQueue.push(id) + if (!this.toastActive) { + this.showNextToast() + } + } + + private showNextToast() { + if (this.toastQueue.length === 0) { + this.toastActive = false + return + } + this.toastActive = true + // toast is shown by the UI layer watching toastQueue + // UI calls toastDismissed() when the toast animation finishes + } + + getToastQueue(): string[] { + return [...this.toastQueue] + } + + toastDismissed(id: string) { + this.toastQueue = this.toastQueue.filter((i) => i !== id) + this.showNextToast() + } + + getUnlockedIds(): string[] { + return [...this.unlockedIds] + } + + getDefinitions(): AchievementDef[] { + return this.definitions + } +} diff --git a/engine/systems/SaveSystem.ts b/engine/systems/SaveSystem.ts index 0e8b9a7..810e58b 100644 --- a/engine/systems/SaveSystem.ts +++ b/engine/systems/SaveSystem.ts @@ -21,17 +21,23 @@ interface WatchedRecord { timestamp: number } +interface AchievementRecord { + achievementId: string +} + class SaveDB extends Dexie { saves!: Table unlocks!: Table watched!: Table + achievements!: Table constructor() { super('MovieGameSaves') - this.version(4).stores({ + this.version(5).stores({ saves: '++id, slot', unlocks: 'chapterId', watched: 'sceneId', + achievements: 'achievementId', }) } } @@ -120,4 +126,16 @@ export class SaveSystem { const records = await db.watched.toArray() return records.map((r) => r.sceneId) } + + async unlockAchievement(id: string) { + const exists = await db.achievements.get(id) + if (!exists) { + await db.achievements.put({ achievementId: id }) + } + } + + async getUnlockedAchievements(): Promise { + const records = await db.achievements.toArray() + return records.map((r) => r.achievementId) + } } diff --git a/engine/types.ts b/engine/types.ts index a1c0445..b3dac3f 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -79,11 +79,21 @@ export interface ChapterInfo { defaultVariables?: Record } +export interface AchievementDef { + id: string + title: string + description: string + icon?: string + hidden?: boolean + condition: Condition +} + export interface GameData { scenes: Record startScene: string variables: Record chapters?: ChapterInfo[] + achievements?: AchievementDef[] } export interface ChoiceRecord { @@ -115,3 +125,4 @@ export type EngineEvent = | 'hotspotRequest' | 'hotspotUpdate' | 'chapterUnlock' + | 'achievementUnlock' diff --git a/public/scenes/demo.json b/public/scenes/demo.json index dec2c82..1259d46 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -5,6 +5,32 @@ "courage": 0, "investigation": 0 }, + "achievements": [ + { + "id": "qte_master", + "title": "反应达人", + "description": "成功完成一次 QTE", + "icon": "", + "hidden": false, + "condition": { "variable": "qte_succeeded", "op": ">=", "value": 1 } + }, + { + "id": "explorer", + "title": "探索者", + "description": "搜索过房间的所有角落", + "icon": "", + "hidden": false, + "condition": { "variable": "investigation", "op": ">=", "value": 2 } + }, + { + "id": "game_finished", + "title": "通关达成", + "description": "完成一次游戏", + "icon": "", + "hidden": false, + "condition": { "variable": "completed_game", "op": ">=", "value": 1 } + } + ], "chapters": [ { "id": "ch1", @@ -175,7 +201,10 @@ "successScene": "qte_success", "failScene": "qte_fail", "effects": { - "success": [{ "type": "add", "target": "courage", "value": 15 }], + "success": [ + { "type": "add", "target": "courage", "value": 15 }, + { "type": "set", "target": "qte_succeeded", "value": 1 } + ], "fail": [{ "type": "add", "target": "trust", "value": -20 }] } } @@ -261,7 +290,10 @@ "alone_ending": { "id": "alone_ending", "videoUrl": "/videos/alone_ending.mp4", - "choices": [] + "choices": [], + "onEnter": [ + { "type": "set", "target": "completed_game", "value": 1 } + ] }, "continue_ending": { "id": "continue_ending", diff --git a/src/App.vue b/src/App.vue index eb31d74..469a92e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -9,6 +9,8 @@ import SaveLoadMenu from '@/components/SaveLoadMenu.vue' import ChapterSelect from '@/components/ChapterSelect.vue' import PlaybackBar from '@/components/PlaybackBar.vue' import LangSwitch from '@/components/LangSwitch.vue' +import AchievementToast from '@/components/AchievementToast.vue' +import AchievementPanel from '@/components/AchievementPanel.vue' import { useGameEngine } from '@/composables/useGameEngine' import { useGameStore } from '@/stores/gameStore' import { useFullscreen } from '@/composables/useFullscreen' @@ -23,6 +25,7 @@ const loading = ref(true) const started = ref(false) const showMenu = ref(false) const showChapterSelect = ref(false) +const showAchievements = ref(false) const hasAutoSave = ref(false) const currentSpeed = ref(1) const canSkip = ref(false) @@ -199,6 +202,7 @@ init() +
{{ t('ui.gameEnd') }}
@@ -220,6 +224,17 @@ init() @select="onStartChapter" @back="showChapterSelect = false" /> + +
@@ -346,6 +361,11 @@ html, body { color: #fc8; } +.achievement-btn { + border-color: rgba(255, 193, 7, 0.3); + color: #ffc107; +} + .game-end-actions { margin-top: 24px; display: flex; diff --git a/src/components/AchievementPanel.vue b/src/components/AchievementPanel.vue new file mode 100644 index 0000000..50e6567 --- /dev/null +++ b/src/components/AchievementPanel.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/src/components/AchievementToast.vue b/src/components/AchievementToast.vue new file mode 100644 index 0000000..f38c74b --- /dev/null +++ b/src/components/AchievementToast.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/composables/useGameEngine.ts b/src/composables/useGameEngine.ts index aace88c..07f18fd 100644 --- a/src/composables/useGameEngine.ts +++ b/src/composables/useGameEngine.ts @@ -26,6 +26,11 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide await saveSystem.markWatched(sceneId) }) + engine.achievementSystem.setUnlockCallback(async (ach) => { + await saveSystem.unlockAchievement(ach.id) + store.addUnlockedAchievement(ach.id) + }) + engine.on('sceneChange', (scene) => { store.setScene(scene) store.clearChoices() @@ -106,8 +111,14 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide engine.sceneManager.load(data) engine.stateManager.init(data.variables) store.setChapters(data.chapters || []) + store.setAchievementDefs(data.achievements || []) + const unlocked = await saveSystem.getUnlockedChapters() store.setUnlockedChapters(unlocked) + + const achieved = await saveSystem.getUnlockedAchievements() + store.setUnlockedAchievementIds(achieved) + engine.achievementSystem.init(data.achievements || [], achieved) } function ensureVideo() { diff --git a/src/stores/gameStore.ts b/src/stores/gameStore.ts index efa7024..c2781a1 100644 --- a/src/stores/gameStore.ts +++ b/src/stores/gameStore.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref, shallowRef } from 'vue' -import type { SceneNode, Choice, QTEDefinition, Hotspot, ChapterInfo } from '@engine/types' +import type { SceneNode, Choice, QTEDefinition, Hotspot, ChapterInfo, AchievementDef } from '@engine/types' export interface SlotInfo { slot: number @@ -29,6 +29,10 @@ export const useGameStore = defineStore('game', () => { const showChapterSelect = ref(false) const chapters = ref([]) const unlockedChapterIds = ref>(new Set()) + const showAchievements = ref(false) + const achievementDefs = ref([]) + const unlockedAchievementIds = ref>(new Set()) + const toastAchievementId = ref('') function setScene(scene: SceneNode) { currentScene.value = scene @@ -126,6 +130,28 @@ export const useGameStore = defineStore('game', () => { showChapterSelect.value = val } + function setShowAchievements(val: boolean) { + showAchievements.value = val + } + + function setAchievementDefs(list: AchievementDef[]) { + achievementDefs.value = list + } + + function setUnlockedAchievementIds(ids: string[]) { + unlockedAchievementIds.value = new Set(ids) + } + + function addUnlockedAchievement(id: string) { + unlockedAchievementIds.value.add(id) + unlockedAchievementIds.value = new Set(unlockedAchievementIds.value) + toastAchievementId.value = id + } + + function clearToastAchievement() { + toastAchievementId.value = '' + } + function dump() { console.group('GameStore') console.log('currentScene:', currentScene.value?.id) @@ -142,13 +168,16 @@ export const useGameStore = defineStore('game', () => { currentScene, choices, gameEnded, timerTotal, timerRemaining, saves, qteActive, qteDef, qteTotal, qteRemaining, qteResult, videoTime, hotspots, isImageScene, showChapterSelect, chapters, unlockedChapterIds, - inputMode, + inputMode, showAchievements, achievementDefs, unlockedAchievementIds, + toastAchievementId, setScene, setChoices, clearChoices, setGameEnded, setTimer, clearTimer, setSaves, showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime, setHotspots, clearHotspots, setIsImageScene, setInputMode, setChapters, setUnlockedChapters, addUnlockedChapter, setShowChapterSelect, + setShowAchievements, setAchievementDefs, setUnlockedAchievementIds, + addUnlockedAchievement, clearToastAchievement, dump, } })