From 92966331d3589877e6474c2d326c17f1abc81cca Mon Sep 17 00:00:00 2001 From: cocos02 Date: Sun, 14 Jun 2026 11:51:32 +0800 Subject: [PATCH] feat: add dev diary and ending thumbnails, update chapter endings display --- ROADMAP.md | 21 ++++++++++ engine/types.ts | 1 + src/components/StoryGallery.vue | 68 ++++++++++++++++++++++++++------- 3 files changed, 76 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ac01367..744df5e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -123,6 +123,27 @@ QTE 成功 → effects: enemy_hp -= 25 - [x] `public/scenes/demo.json` — 新增 `combat_router` 条件路由示例 - [x] 验证:TypeScript + Vite build 通过 +### P26 关键节点过滤 — StoryGallery 只展示剧情分叉点 ✅ 已完成 2026-06-12 + +目标:StoryGallery 不再展示所有场景,只展示剧情关键节点(章节起始、选择分支点、结局点)。 +QTE 场景和过渡/路由场景被过滤,子节点上浮一级。 + +**三层判断:** + +| 优先级 | 来源 | 说明 | +|:--:|------|------| +| 1 | `SceneNode.keyMoment` | 手动覆盖。`true`=强制展示,`false`=强制隐藏 | +| 2 | `endings[].sceneId` | 结局节点 | +| 3 | 自动判断 | 章节起点 / 有 `choices`(分支点)→ 关键节点。QTE 不算 | + +**扁平化:** 非关键节点不渲染,子节点上浮到父节点层级,路径语义不变。 + +**实现清单:** + +- [x] `engine/types.ts` — `SceneNode.keyMoment?: boolean` +- [x] `src/components/StoryGallery.vue` — `isKeyMoment()` 三层逻辑 + `collectKeyTargets()` 扁平化非关键节点 +- [x] 验证:TypeScript + Vite build 通过 + ## 已完成 P0~P23 全部实现(除 P18)。详见 [CHANGELOG.md](CHANGELOG.md)。 diff --git a/engine/types.ts b/engine/types.ts index 4ea5ce3..3f8be20 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -22,6 +22,7 @@ export interface SceneNode { videoMuted?: boolean skippable?: boolean streamingUrl?: Record + keyMoment?: boolean } export interface Choice { diff --git a/src/components/StoryGallery.vue b/src/components/StoryGallery.vue index fa0e681..e332f75 100644 --- a/src/components/StoryGallery.vue +++ b/src/components/StoryGallery.vue @@ -117,6 +117,43 @@ function endingStatus(endingId: string) { return props.visitedIds.has(props.endings.find(e => e.id === endingId)?.sceneId ?? '') } +function isKeyMoment(sceneId: string): boolean { + const scene = props.scenes[sceneId] + if (!scene) return false + if (scene.keyMoment !== undefined) return scene.keyMoment + if (props.chapters.some(c => c.startScene === sceneId)) return true + if (scene.choices && scene.choices.length > 0) return true + if (props.endings.some(e => e.sceneId === sceneId)) return true + return false +} + +function collectKeyTargets(sceneId: string, depth: number, pathSet: Set): string[] { + if (depth > 10 || pathSet.has(sceneId)) return [] + pathSet.add(sceneId) + const scene = props.scenes[sceneId] + if (!scene) return [] + if (isKeyMoment(sceneId)) return [sceneId] + const results: string[] = [] + function pushTarget(target: string | undefined) { + if (!target) return + results.push(...collectKeyTargets(target, depth + 1, pathSet)) + } + if (scene.choices) for (const c of scene.choices) pushTarget(c.targetScene) + if (scene.nextScene) { + if (Array.isArray(scene.nextScene)) { + for (const r of scene.nextScene) pushTarget(r.targetScene) + } else { + pushTarget(scene.nextScene) + } + } + if (scene.qte) { + pushTarget(scene.qte.successScene) + pushTarget(scene.qte.failScene) + } + if (scene.hotspots) for (const h of scene.hotspots) pushTarget(h.targetScene) + return results +} + function resolveInitialChapter(): string { const saved = localStorage.getItem('story_chapter') if (saved && props.chapters.some(c => c.id === saved)) return saved @@ -146,21 +183,24 @@ function buildPlayerTree(sceneId: string, chapterId: string, depth: number, path if (scene) { function pushChild(target: string | undefined) { if (!target) return - if (isOtherChapterStart(target, chapterId)) { - const gatewayCh = props.chapters.find(c => c.startScene === target) - children.push({ - sceneId: '', - label: gatewayCh ? (t(gatewayCh.labelKey || gatewayCh.label)) : target, - visited: false, - locked: !props.unlockedChapterIds.has(gatewayCh?.id ?? ''), - children: [], - isGateway: true, - gatewayChapterId: gatewayCh?.id, - }) - return + const keyIds = collectKeyTargets(target, 0, new Set()) + for (const keyId of keyIds) { + if (isOtherChapterStart(keyId, chapterId)) { + const gatewayCh = props.chapters.find(c => c.startScene === keyId) + children.push({ + sceneId: '', + label: gatewayCh ? (t(gatewayCh.labelKey || gatewayCh.label)) : keyId, + visited: false, + locked: !props.unlockedChapterIds.has(gatewayCh?.id ?? ''), + children: [], + isGateway: true, + gatewayChapterId: gatewayCh?.id, + }) + } else { + const child = buildPlayerTree(keyId, chapterId, depth + 1, pathSet) + if (child) children.push(child) + } } - const child = buildPlayerTree(target, chapterId, depth + 1, pathSet) - if (child) children.push(child) } if (scene.choices) { for (const c of scene.choices) pushChild(c.targetScene)