feat: P25 conditional routing, nextScene supports Choice[] with conditions

This commit is contained in:
2026-06-13 00:50:48 +08:00
parent db4f06883d
commit e949a84171
7 changed files with 108 additions and 10 deletions

View File

@@ -75,6 +75,54 @@ Web 版不打包视频文件,用户手动选择超清/高清/标清,系统
- [ ] 验证:浏览器 `window.__ELECTRON__` = undefined设置面板显示画质下拉 - [ ] 验证:浏览器 `window.__ELECTRON__` = undefined设置面板显示画质下拉
- [ ] 验证:`pack:html` 产物不包含 `videos/` 目录 - [ ] 验证:`pack:html` 产物不包含 `videos/` 目录
### P25 条件路由 — nextScene 支持条件数组 ✅ 已完成 2026-06-12
目标:`nextScene` 从单一场景 ID 扩展为条件路由数组。第一个满足条件的场景自动跳转,
否则 fallback 到末尾无条件的默认场景。不局限于 QTE所有场景均可使用。
**场景数据设计:**
```json
{
"id": "combat_router",
"nextScene": [
{ "conditions": [{ "variable": "enemy_hp", "op": "<=", "value": 0 }], "targetScene": "victory" },
{ "conditions": [{ "variable": "player_hp", "op": "<=", "value": 0 }], "targetScene": "defeat" },
{ "targetScene": "combat" }
]
}
```
**引擎行为:**
```
onVideoEnd(scene)
├── nextScene 是 string→ 现存逻辑不变
├── nextScene 是 Choice[]
│ → 遍历数组,第一个满足 conditions 的 → 跳转到它的 targetScene
│ → 都不满足 → endGame()
└── 无 nextScene → 现有逻辑不变
```
**使用场景:**
```
QTE 成功 → effects: enemy_hp -= 25
→ successScene = "combat_router"
├── enemy_hp <= 0 → victory 场景
├── player_hp <= 0 → defeat 场景
└── 否则 → 回到 QTE 场景(循环)
```
**实现清单:**
- [x] `engine/types.ts``SceneNode.nextScene` 类型改为 `string | Choice[]`
- [x] `engine/core/Engine.ts``onVideoEnd` 中加数组判断,遍历 conditions 跳转
- [x] `engine/core/SceneManager.ts``getCandidateTargetIds` 支持数组 nextScene
- [x] `src/components/StoryGallery.vue` — BFS 遍历 + `buildPlayerTree` 支持数组 nextScene
- [x] `public/scenes/demo.json` — 新增 `combat_router` 条件路由示例
- [x] 验证TypeScript + Vite build 通过
## 已完成 ## 已完成
P0~P23 全部实现(除 P18。详见 [CHANGELOG.md](CHANGELOG.md)。 P0~P23 全部实现(除 P18。详见 [CHANGELOG.md](CHANGELOG.md)。

7
docs/使用经验.md Normal file
View File

@@ -0,0 +1,7 @@
带选择的场景用单独的一个场景和视频表示。配置循环0.1到视频长度-0.1.效果就是进入视频就会出现选择而且还能循环播放视频
用户进攻是否成功由qte决定 用户防守是否成功由qte决定
战斗 |<- 用户进攻回合 用户进攻回合 ->| |<- 用户防守回合 用户防守回合 ->|
ready -> 用户选择进攻qte -> qte_success -> 播放玩家进攻敌人不防守动画 -> 用户选择防守qte -> qte_sucess -> 用户防守敌人进攻 -> 回到 用户选择进攻qte节点
-> qte_fail -> 播放玩家进攻敌人防守动画 -> 用户选择防守qte -> qte_fail -> 用户不防守敌人进攻 ->

View File

@@ -296,11 +296,26 @@ export class Engine {
} }
) )
} else if (scene.nextScene) { } else if (scene.nextScene) {
const next = this.sceneManager.getScene(scene.nextScene) if (Array.isArray(scene.nextScene)) {
if (next) { for (const route of scene.nextScene) {
this.goToScene(next) if (!route.conditions || this.stateManager.evaluate(route.conditions)) {
} else { const next = this.sceneManager.getScene(route.targetScene)
if (next) {
this.goToScene(next)
} else {
this.endGame()
}
return
}
}
this.endGame() this.endGame()
} else {
const next = this.sceneManager.getScene(scene.nextScene)
if (next) {
this.goToScene(next)
} else {
this.endGame()
}
} }
} else if (scene.hotspots?.length) { } else if (scene.hotspots?.length) {
return return

View File

@@ -50,8 +50,13 @@ export class SceneManager {
} }
} }
if (scene.nextScene && !targets.includes(scene.nextScene)) { if (scene.nextScene) {
targets.push(scene.nextScene) const nextIds = Array.isArray(scene.nextScene)
? scene.nextScene.map(r => r.targetScene)
: [scene.nextScene]
for (const id of nextIds) {
if (!targets.includes(id)) targets.push(id)
}
} }
return targets return targets

View File

@@ -10,7 +10,7 @@ export interface SceneNode {
choices?: Choice[] choices?: Choice[]
hotspots?: Hotspot[] hotspots?: Hotspot[]
qte?: QTEDefinition qte?: QTEDefinition
nextScene?: string nextScene?: string | Choice[]
onEnter?: Effect[] onEnter?: Effect[]
loopStart?: number loopStart?: number
loopEnd?: number loopEnd?: number

View File

@@ -549,6 +549,15 @@
}, },
"choices": [], "choices": [],
"thumbnail": "shared/thumb.jpg" "thumbnail": "shared/thumb.jpg"
},
"combat_router": {
"id": "combat_router",
"videoUrl": "",
"nextScene": [
{ "conditions": [{ "variable": "courage", "op": ">=", "value": 20 }], "targetScene": "trust_ending" },
{ "conditions": [{ "variable": "courage", "op": "<=", "value": -10 }], "targetScene": "alone_ending" },
{ "targetScene": "right_door" }
]
} }
} }
} }

View File

@@ -41,8 +41,16 @@ function collectReachable(startId: string, chapterId: string): Set<string> {
queue.push(c.targetScene) queue.push(c.targetScene)
} }
} }
if (scene.nextScene && !visited.has(scene.nextScene) && !isOtherChapterStart(scene.nextScene, chapterId)) if (scene.nextScene) {
queue.push(scene.nextScene) if (Array.isArray(scene.nextScene)) {
for (const r of scene.nextScene) {
if (!visited.has(r.targetScene) && !isOtherChapterStart(r.targetScene, chapterId))
queue.push(r.targetScene)
}
} else if (!visited.has(scene.nextScene) && !isOtherChapterStart(scene.nextScene, chapterId)) {
queue.push(scene.nextScene)
}
}
if (scene.qte) { if (scene.qte) {
if (scene.qte.successScene && !visited.has(scene.qte.successScene) && !isOtherChapterStart(scene.qte.successScene, chapterId)) if (scene.qte.successScene && !visited.has(scene.qte.successScene) && !isOtherChapterStart(scene.qte.successScene, chapterId))
queue.push(scene.qte.successScene) queue.push(scene.qte.successScene)
@@ -157,7 +165,13 @@ function buildPlayerTree(sceneId: string, chapterId: string, depth: number, path
if (scene.choices) { if (scene.choices) {
for (const c of scene.choices) pushChild(c.targetScene) for (const c of scene.choices) pushChild(c.targetScene)
} }
pushChild(scene.nextScene) if (scene.nextScene) {
if (Array.isArray(scene.nextScene)) {
for (const r of scene.nextScene) pushChild(r.targetScene)
} else {
pushChild(scene.nextScene)
}
}
if (scene.qte) { if (scene.qte) {
pushChild(scene.qte.successScene) pushChild(scene.qte.successScene)
pushChild(scene.qte.failScene) pushChild(scene.qte.failScene)