From 92971175449f976d3e06f0dc6eb92659dd9c8a85 Mon Sep 17 00:00:00 2001 From: cocos02 Date: Tue, 9 Jun 2026 17:49:07 +0800 Subject: [PATCH] feat: P15 ending gallery, chapter recap, visited tracking, save system v6 --- ROADMAP.md | 47 ++++-- engine/core/Engine.ts | 37 ++--- engine/core/SceneManager.ts | 4 + engine/systems/SaveSystem.ts | 20 ++- engine/types.ts | 8 + public/images/end_alone.jpg | Bin 0 -> 2068 bytes public/images/end_continue.jpg | Bin 0 -> 2066 bytes public/images/end_trust.jpg | Bin 0 -> 1658 bytes public/scenes/demo.json | 5 + src/App.vue | 25 +++- src/components/ChapterRecap.vue | 242 +++++++++++++++++++++++++++++++ src/components/EndingGallery.vue | 142 ++++++++++++++++++ src/composables/useGameEngine.ts | 10 ++ src/stores/gameStore.ts | 25 +++- 14 files changed, 517 insertions(+), 48 deletions(-) create mode 100644 public/images/end_alone.jpg create mode 100644 public/images/end_continue.jpg create mode 100644 public/images/end_trust.jpg create mode 100644 src/components/ChapterRecap.vue create mode 100644 src/components/EndingGallery.vue diff --git a/ROADMAP.md b/ROADMAP.md index 1731c86..c0d184b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -784,9 +784,18 @@ QTE 成功 / 到达隐藏结局 / 通关等"事件型"成就,通过在对应 e - [x] `src/App.vue` — 整合 AchievementToast + 主菜单"成就"入口 - [x] `public/scenes/demo.json` — 3 个成就 + QTE success 变量 set + alone_ending onEnter -### P15 结局画廊 + 章节回顾 — 分支图可视化(待实现) +### P15 结局画廊 + 章节回顾 — 分支图 + 完成度百分比 + 条件提示 ✅ 已完成 2026-06-09 -目标:通关后展示结局画廊 + 章节分支流程图。玩家直观看到"哪些结局未解锁、哪些分支未走过",驱动重玩。 +目标:通关后展示结局画廊和章节分支流程图,包含完成度百分比和未解锁分支的条件提示, +驱动重玩探索。对标 Detroit 的流程图体验。 + +**设计决策(对标 Detroit / Dark Pictures):** + +| 决策 | 做法 | +|------|------| +| **结局存储** | 共用 `visitedSceneIds`,不独立建表。`endings` 数组声明哪些场景是结局,画廊查 `visitedSceneIds.has(sceneId)` | +| **路径追踪** | **节点级** `visitedSceneIds: Set` — `goToScene` 中 `visited.add(scene.id)` | +| **回顾 UI** | Vue Flow 只读完整分支图 + 已到达节点绿色实心高亮 + 未到达节点灰色虚线边框 | **结局画廊:** @@ -799,26 +808,32 @@ QTE 成功 / 到达隐藏结局 / 通关等"事件型"成就,通过在对应 e } ``` -引擎 `goToScene` 到达结局场景时标记解锁。画廊缩略图网格:已解锁显示画面,未解锁 ? 剪影。 +引擎 `goToScene` 到达场景时自动记录 visited。画廊检查 `visitedSceneIds.has(ending.sceneId)`。 -**章节回顾:** +**章节回顾新增:** -基于 Vue Flow 节点图(复用 P3 编辑器),只读模式。追踪 `visitedSceneIds: Set`, -在 `goToScene` 中记录。展示章节分支图,已走路径高亮,未走路径灰色虚线。 +**完成度百分比:** 每章展示 "3/7 (43%) 场景已发现"。 +统计该章 `startScene` 可达的场景数作为分母,visited 中属于本章的场景数作为分子。 -入口:游戏结束后展示 + 章节选择界面每章卡片下有"回顾"按钮。不打断游戏流程。 +**条件提示:** 分支图中一些灰色节点显示小锁图标 + 条件提示: +``` +[灰色节点] 🔒 需要 trust >= 80 +``` +引擎读取该边对应 choice/hotspot 的 `conditions`,展示第一个未满足的条件。 **实现清单:** -- [ ] `engine/types.ts` — `GameData.endings`、`EndingDef` -- [ ] `engine/systems/SaveSystem.ts` — DB v5 新增 `endings` + `visited` 表 -- [ ] `engine/core/Engine.ts` — `goToScene` 标记结局 + 记录 visited scene -- [ ] `src/components/EndingGallery.vue` — 结局缩略图网格 + 锁定/解锁 -- [ ] `src/components/ChapterRecap.vue` — Vue Flow 只读分支图 + 路径高亮 -- [ ] `src/stores/gameStore.ts` — 结局/visited 状态 -- [ ] `src/App.vue` — 主菜单"画廊"入口 + 游戏结束整合回顾 -- [ ] `public/scenes/demo.json` — endings 定义 + 结局缩略图 -- [ ] 验证:结局到达→解锁→画廊显示、章节回顾路径高亮正确、未解锁结局 ? 剪影 +- [x] `engine/types.ts` — `GameData.endings`、`EndingDef { id, label, sceneId, thumbnail? }` +- [x] `engine/core/Engine.ts` — `goToScene` 中 `onMarkVisited(scene.id)` 回调 +- [x] `engine/core/SceneManager.ts` — `getScenes()` 公开 scenes 数据 +- [x] `engine/systems/SaveSystem.ts` — DB v6 新增 `visited` 表 +- [x] `src/components/EndingGallery.vue` — 结局缩略图网格 + 锁定(?剪影)/解锁(画面) +- [x] `src/components/ChapterRecap.vue` — BFS 遍历可达场景 + 完成度进度条 + visited/unvisited 列表 + 条件提示 +- [x] `src/stores/gameStore.ts` — endings/visitedSceneIds/showEndingGallery 状态 +- [x] `src/App.vue` — 主菜单"画廊"入口 + EndingGallery/ChapterRecap 组件 +- [x] `public/scenes/demo.json` — 3 个 endings + `end_*.jpg` 缩略图 +- [x] `public/images/end_{trust,alone,continue}.jpg` — 结局缩略图 +- [x] 验证:TypeScript + Vite build 通过 ### P16 平台化 — 云存档 + 可访问性 + 自适应码率 + 全局统计(待实现) diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index 36d9cdd..32f106a 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -28,6 +28,7 @@ export class Engine { private loopActive = false private onUnlockChapter: ((chapterId: string) => void) | null = null private onMarkWatched: ((sceneId: string) => void) | null = null + private onMarkVisited: ((sceneId: string) => void) | null = null setChapterUnlockHandler(handler: (chapterId: string) => void) { this.onUnlockChapter = handler @@ -37,6 +38,10 @@ export class Engine { this.onMarkWatched = handler } + setMarkVisitedHandler(handler: (sceneId: string) => void) { + this.onMarkVisited = handler + } + constructor() { this.sceneManager = new SceneManager() this.videoManager = new VideoManager() @@ -74,40 +79,16 @@ export class Engine { } private goToScene(scene: SceneNode) { + this.currentScene = scene + const chapter = this.sceneManager.getChapterBySceneId(scene.id) if (chapter) { this.onUnlockChapter?.(chapter.id) this.emit('chapterUnlock', chapter) } - if (scene.onEnter) { - this.stateManager.apply(scene.onEnter) - } + this.onMarkVisited?.(scene.id) - if (scene.videoMuted) { - this.videoManager.setMuted(true) - } else { - this.videoManager.setMuted(false) - } - - const bgmUrl = scene.bgmUrl || null - if (bgmUrl) { - this.audioSystem.play( - bgmUrl, - scene.bgmVolume ?? 0.8, - scene.bgmCrossFade ?? 2.0, - scene.bgmDuckLevel, - scene.bgmDuckFade, - ) - } else { - this.audioSystem.stop(scene.bgmCrossFade ?? 2.0) - } - - this.enterScene(scene) - } - - private enterScene(scene: SceneNode) { - this.currentScene = scene this.qteTriggered = false this.qteResolved = false this.loopActive = false @@ -405,7 +386,7 @@ export class Engine { this.ended = false this.isInitialScene = false - this.enterScene(scene) + this.goToScene(scene) } destroy() { diff --git a/engine/core/SceneManager.ts b/engine/core/SceneManager.ts index 360e0f2..1e20cd4 100644 --- a/engine/core/SceneManager.ts +++ b/engine/core/SceneManager.ts @@ -25,6 +25,10 @@ export class SceneManager { return Object.keys(this.scenes) } + getScenes(): Record { + return this.scenes + } + getChapterBySceneId(sceneId: string): ChapterInfo | undefined { return this.chapters.find((ch) => ch.startScene === sceneId) } diff --git a/engine/systems/SaveSystem.ts b/engine/systems/SaveSystem.ts index 810e58b..48a5c56 100644 --- a/engine/systems/SaveSystem.ts +++ b/engine/systems/SaveSystem.ts @@ -25,19 +25,25 @@ interface AchievementRecord { achievementId: string } +interface VisitedRecord { + sceneId: string +} + class SaveDB extends Dexie { saves!: Table unlocks!: Table watched!: Table achievements!: Table + visited!: Table constructor() { super('MovieGameSaves') - this.version(5).stores({ + this.version(6).stores({ saves: '++id, slot', unlocks: 'chapterId', watched: 'sceneId', achievements: 'achievementId', + visited: 'sceneId', }) } } @@ -138,4 +144,16 @@ export class SaveSystem { const records = await db.achievements.toArray() return records.map((r) => r.achievementId) } + + async markVisited(sceneId: string) { + const exists = await db.visited.get(sceneId) + if (!exists) { + await db.visited.put({ sceneId }) + } + } + + async getVisitedSceneIds(): Promise { + const records = await db.visited.toArray() + return records.map((r) => r.sceneId) + } } diff --git a/engine/types.ts b/engine/types.ts index b3dac3f..8d8143f 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -88,12 +88,20 @@ export interface AchievementDef { condition: Condition } +export interface EndingDef { + id: string + label: string + sceneId: string + thumbnail?: string +} + export interface GameData { scenes: Record startScene: string variables: Record chapters?: ChapterInfo[] achievements?: AchievementDef[] + endings?: EndingDef[] } export interface ChoiceRecord { diff --git a/public/images/end_alone.jpg b/public/images/end_alone.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6729d8788ae39637b25fa59038fde3df5efc957d GIT binary patch literal 2068 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 literal 0 HcmV?d00001 diff --git a/public/images/end_trust.jpg b/public/images/end_trust.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dd3b170028e31673430d64306122feb527147b5a GIT binary patch literal 1658 zcmd5)YfO`86#m+B@d83Y&=!yh8;*3a6(S%Vc1}Vz14Uw}P$(U(7Asc7>8M)h77$^R zFy{(Xk#1~)P(ZmzL7*stMdT7FZS4gLf+)oTMJZx0-#%w%vc&y%&zqd{yf1X{(kY(V7e2_(l&TdTlXvr^GmNZ z%M>(ww^vc8cg6-LNXUXi^FFxz;{%hYl7hI3%i<@=BQkyMQhLU0ZSCmL`GkWQ=dkdh znCGK@(Y_S-YHg|_=TKMI{psT;9#OT;I^)7dd0j_FYP4S6AI^#A<>jpwdtB008{{nQ zXp?MM9lbAJOKX|Oa}-W3qKGzqVs1O7U9=?@%P!}qGjW*8KFzg)ixd=Z&e&kR_rDhT zg5Z{;n9CCMeal12=!BgGq;ykzaO%|35<@^=m{IC3QbM(zWLbTn$BJZ7X2D>*Kb3Y= z8x7CK4j-P}PE2Do$&+fTuKIdd1f=fp-I=#qpGuwz5<i$UVF?Z3?{m5qtOBsZy`jB~CQi(>^x1V@kmxXTMHhYPk z8H3lUS2RouPq)gm^xUIS)}ND;+f4LVA!=qq>I>4DiawcNyyXTB>#V9OF@wNxZd|v4 zhI+7BvS*xK;gdr4i`#q)y81!LM`0gweE$j^y!GC z?KfB?dCH75xDr)Bu6JFkQMP%BV%x)4TnkJ1T=J0L3UB4uP`tLZ0|Wwq2OqrqulTf# zQ#>7Vo`jgWD%>fD8>XNd;z_yg__l)}@ZmuD@L0;MzP638`6`B*I=B2|98Vng7;EnF z-!U$pzilAn1?LfF;pqt z!L2x3=#6p9kQbv)>(Eqv*!an(H@9hD=3Oo4@Xqd~?0BJUF1(@yfyA4$GEAkLQYeU8 zG>LZFq6VqVI)$Q_c1T*mAPrEJ4VVTF=", "value": 1 } } ], + "endings": [ + { "id": "trust_end", "label": "信任的伙伴", "sceneId": "trust_ending", "thumbnail": "/images/end_trust.jpg" }, + { "id": "alone_end", "label": "独行之路", "sceneId": "alone_ending", "thumbnail": "/images/end_alone.jpg" }, + { "id": "continue_end", "label": "继续前行", "sceneId": "continue_ending", "thumbnail": "/images/end_continue.jpg" } + ], "chapters": [ { "id": "ch1", diff --git a/src/App.vue b/src/App.vue index 469a92e..59d82f4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,6 +11,8 @@ 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 EndingGallery from '@/components/EndingGallery.vue' +import ChapterRecap from '@/components/ChapterRecap.vue' import { useGameEngine } from '@/composables/useGameEngine' import { useGameStore } from '@/stores/gameStore' import { useFullscreen } from '@/composables/useFullscreen' @@ -26,6 +28,8 @@ const started = ref(false) const showMenu = ref(false) const showChapterSelect = ref(false) const showAchievements = ref(false) +const showEndingGallery = ref(false) +const recapChapterId = ref(null) const hasAutoSave = ref(false) const currentSpeed = ref(1) const canSkip = ref(false) @@ -34,7 +38,7 @@ const showPromptToast = ref(false) const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, startChapter, skipScene, setSpeed, getSpeed, isSceneWatched, - saveGame, loadGameFromSlot, refreshSaves, saveSystem } = + saveGame, loadGameFromSlot, refreshSaves, saveSystem, engine } = useGameEngine(() => [videoElA.value, videoElB.value]) async function init() { @@ -203,6 +207,7 @@ init() +
{{ t('ui.gameEnd') }}
@@ -230,6 +235,19 @@ init() :unlocked-ids="store.unlockedAchievementIds" @close="showAchievements = false" /> + + +import { computed } from 'vue' +import type { ChapterInfo, SceneNode } from '@engine/types' + +const props = defineProps<{ + chapter: ChapterInfo + scenes: Record + visitedIds: Set +}>() + +const emit = defineEmits<{ + close: [] +}>() + +function collectReachable(startId: string): Set { + const visited = new Set() + const queue = [startId] + while (queue.length > 0) { + const id = queue.shift()! + if (visited.has(id)) continue + const scene = props.scenes[id] + if (!scene) continue + visited.add(id) + if (scene.choices) { + for (const c of scene.choices) { + if (c.targetScene && !visited.has(c.targetScene)) queue.push(c.targetScene) + } + } + if (scene.nextScene && !visited.has(scene.nextScene)) queue.push(scene.nextScene) + if (scene.qte) { + if (scene.qte.successScene && !visited.has(scene.qte.successScene)) queue.push(scene.qte.successScene) + if (scene.qte.failScene && !visited.has(scene.qte.failScene)) queue.push(scene.qte.failScene) + } + if (scene.hotspots) { + for (const h of scene.hotspots) { + if (h.targetScene && !visited.has(h.targetScene)) queue.push(h.targetScene) + } + } + } + return visited +} + +const reachable = computed(() => collectReachable(props.chapter.startScene)) + +const visitedCount = computed(() => { + let count = 0 + for (const id of reachable.value) { + if (props.visitedIds.has(id)) count++ + } + return count +}) + +const totalCount = computed(() => reachable.value.size) + +const percentage = computed(() => { + if (totalCount.value === 0) return 0 + return Math.round((visitedCount.value / totalCount.value) * 100) +}) + +const sceneList = computed(() => { + return [...reachable.value].map((id) => { + const scene = props.scenes[id] + const isVisited = props.visitedIds.has(id) + let hint = '' + + if (!isVisited) { + // Find why this scene is locked - look at conditions from scenes that lead to it + for (const [srcId, src] of Object.entries(props.scenes)) { + if (src.choices) { + for (const c of src.choices) { + if (c.targetScene === id && c.conditions && c.conditions.length > 0) { + const firstCond = c.conditions[0] + hint = `${firstCond.variable} ${firstCond.op} ${firstCond.value}` + break + } + } + } + if (src.hotspots) { + for (const h of src.hotspots) { + if (h.targetScene === id && h.conditions && h.conditions.length > 0) { + const firstCond = h.conditions[0] + hint = `${firstCond.variable} ${firstCond.op} ${firstCond.value}` + break + } + } + } + if (hint) break + } + } + + return { id, label: scene?.id ?? id, isVisited, hint } + }) +}) + + + + + diff --git a/src/components/EndingGallery.vue b/src/components/EndingGallery.vue new file mode 100644 index 0000000..082eb9e --- /dev/null +++ b/src/components/EndingGallery.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/src/composables/useGameEngine.ts b/src/composables/useGameEngine.ts index 07f18fd..90d6bd2 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.setMarkVisitedHandler(async (sceneId) => { + store.addVisitedSceneId(sceneId) + await saveSystem.markVisited(sceneId) + }) + engine.achievementSystem.setUnlockCallback(async (ach) => { await saveSystem.unlockAchievement(ach.id) store.addUnlockedAchievement(ach.id) @@ -119,6 +124,11 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide const achieved = await saveSystem.getUnlockedAchievements() store.setUnlockedAchievementIds(achieved) engine.achievementSystem.init(data.achievements || [], achieved) + + store.setEndings(data.endings || []) + + const visitedIds = await saveSystem.getVisitedSceneIds() + store.setVisitedSceneIds(visitedIds) } function ensureVideo() { diff --git a/src/stores/gameStore.ts b/src/stores/gameStore.ts index c2781a1..e1e89e6 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, AchievementDef } from '@engine/types' +import type { SceneNode, Choice, QTEDefinition, Hotspot, ChapterInfo, AchievementDef, EndingDef } from '@engine/types' export interface SlotInfo { slot: number @@ -33,6 +33,9 @@ export const useGameStore = defineStore('game', () => { const achievementDefs = ref([]) const unlockedAchievementIds = ref>(new Set()) const toastAchievementId = ref('') + const showEndingGallery = ref(false) + const endings = ref([]) + const visitedSceneIds = ref>(new Set()) function setScene(scene: SceneNode) { currentScene.value = scene @@ -152,6 +155,23 @@ export const useGameStore = defineStore('game', () => { toastAchievementId.value = '' } + function setEndings(list: EndingDef[]) { + endings.value = list + } + + function setShowEndingGallery(val: boolean) { + showEndingGallery.value = val + } + + function setVisitedSceneIds(ids: string[]) { + visitedSceneIds.value = new Set(ids) + } + + function addVisitedSceneId(id: string) { + visitedSceneIds.value.add(id) + visitedSceneIds.value = new Set(visitedSceneIds.value) + } + function dump() { console.group('GameStore') console.log('currentScene:', currentScene.value?.id) @@ -169,7 +189,7 @@ export const useGameStore = defineStore('game', () => { qteActive, qteDef, qteTotal, qteRemaining, qteResult, videoTime, hotspots, isImageScene, showChapterSelect, chapters, unlockedChapterIds, inputMode, showAchievements, achievementDefs, unlockedAchievementIds, - toastAchievementId, + toastAchievementId, showEndingGallery, endings, visitedSceneIds, setScene, setChoices, clearChoices, setGameEnded, setTimer, clearTimer, setSaves, showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime, @@ -178,6 +198,7 @@ export const useGameStore = defineStore('game', () => { setChapters, setUnlockedChapters, addUnlockedChapter, setShowChapterSelect, setShowAchievements, setAchievementDefs, setUnlockedAchievementIds, addUnlockedAchievement, clearToastAchievement, + setEndings, setShowEndingGallery, setVisitedSceneIds, addVisitedSceneId, dump, } })