feat: add dev diary and ending thumbnails, update chapter endings display
This commit is contained in:
21
ROADMAP.md
21
ROADMAP.md
@@ -123,6 +123,27 @@ QTE 成功 → effects: enemy_hp -= 25
|
|||||||
- [x] `public/scenes/demo.json` — 新增 `combat_router` 条件路由示例
|
- [x] `public/scenes/demo.json` — 新增 `combat_router` 条件路由示例
|
||||||
- [x] 验证:TypeScript + Vite build 通过
|
- [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)。
|
P0~P23 全部实现(除 P18)。详见 [CHANGELOG.md](CHANGELOG.md)。
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface SceneNode {
|
|||||||
videoMuted?: boolean
|
videoMuted?: boolean
|
||||||
skippable?: boolean
|
skippable?: boolean
|
||||||
streamingUrl?: Record<string, string>
|
streamingUrl?: Record<string, string>
|
||||||
|
keyMoment?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Choice {
|
export interface Choice {
|
||||||
|
|||||||
@@ -117,6 +117,43 @@ function endingStatus(endingId: string) {
|
|||||||
return props.visitedIds.has(props.endings.find(e => e.id === endingId)?.sceneId ?? '')
|
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>): 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 {
|
function resolveInitialChapter(): string {
|
||||||
const saved = localStorage.getItem('story_chapter')
|
const saved = localStorage.getItem('story_chapter')
|
||||||
if (saved && props.chapters.some(c => c.id === saved)) return saved
|
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) {
|
if (scene) {
|
||||||
function pushChild(target: string | undefined) {
|
function pushChild(target: string | undefined) {
|
||||||
if (!target) return
|
if (!target) return
|
||||||
if (isOtherChapterStart(target, chapterId)) {
|
const keyIds = collectKeyTargets(target, 0, new Set())
|
||||||
const gatewayCh = props.chapters.find(c => c.startScene === target)
|
for (const keyId of keyIds) {
|
||||||
children.push({
|
if (isOtherChapterStart(keyId, chapterId)) {
|
||||||
sceneId: '',
|
const gatewayCh = props.chapters.find(c => c.startScene === keyId)
|
||||||
label: gatewayCh ? (t(gatewayCh.labelKey || gatewayCh.label)) : target,
|
children.push({
|
||||||
visited: false,
|
sceneId: '',
|
||||||
locked: !props.unlockedChapterIds.has(gatewayCh?.id ?? ''),
|
label: gatewayCh ? (t(gatewayCh.labelKey || gatewayCh.label)) : keyId,
|
||||||
children: [],
|
visited: false,
|
||||||
isGateway: true,
|
locked: !props.unlockedChapterIds.has(gatewayCh?.id ?? ''),
|
||||||
gatewayChapterId: gatewayCh?.id,
|
children: [],
|
||||||
})
|
isGateway: true,
|
||||||
return
|
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) {
|
if (scene.choices) {
|
||||||
for (const c of scene.choices) pushChild(c.targetScene)
|
for (const c of scene.choices) pushChild(c.targetScene)
|
||||||
|
|||||||
Reference in New Issue
Block a user