From e949a841714f68c8c123cd9a83a933f04c3a7f67 Mon Sep 17 00:00:00 2001 From: cocos02 Date: Sat, 13 Jun 2026 00:50:48 +0800 Subject: [PATCH] feat: P25 conditional routing, nextScene supports Choice[] with conditions --- ROADMAP.md | 48 +++++++++++++++++++++++++++++++++ docs/使用经验.md | 7 +++++ engine/core/Engine.ts | 23 +++++++++++++--- engine/core/SceneManager.ts | 9 +++++-- engine/types.ts | 2 +- public/scenes/demo.json | 9 +++++++ src/components/StoryGallery.vue | 20 +++++++++++--- 7 files changed, 108 insertions(+), 10 deletions(-) create mode 100644 docs/使用经验.md diff --git a/ROADMAP.md b/ROADMAP.md index 3987739..ac01367 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -75,6 +75,54 @@ Web 版不打包视频文件,用户手动选择超清/高清/标清,系统 - [ ] 验证:浏览器 `window.__ELECTRON__` = undefined,设置面板显示画质下拉 - [ ] 验证:`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)。 diff --git a/docs/使用经验.md b/docs/使用经验.md new file mode 100644 index 0000000..335f484 --- /dev/null +++ b/docs/使用经验.md @@ -0,0 +1,7 @@ +带选择的场景,用单独的一个场景和视频表示。配置循环0.1到视频长度-0.1.效果就是进入视频就会出现选择而且还能循环播放视频 + + + 用户进攻是否成功由qte决定 用户防守是否成功由qte决定 +战斗 |<- 用户进攻回合 用户进攻回合 ->| |<- 用户防守回合 用户防守回合 ->| +ready -> 用户选择进攻qte -> qte_success -> 播放玩家进攻敌人不防守动画 -> 用户选择防守qte -> qte_sucess -> 用户防守敌人进攻 -> 回到 用户选择进攻qte节点 + -> qte_fail -> 播放玩家进攻敌人防守动画 -> 用户选择防守qte -> qte_fail -> 用户不防守敌人进攻 -> \ No newline at end of file diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index 89151f8..8554f1f 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -296,11 +296,26 @@ export class Engine { } ) } else if (scene.nextScene) { - const next = this.sceneManager.getScene(scene.nextScene) - if (next) { - this.goToScene(next) - } else { + if (Array.isArray(scene.nextScene)) { + for (const route of scene.nextScene) { + if (!route.conditions || this.stateManager.evaluate(route.conditions)) { + const next = this.sceneManager.getScene(route.targetScene) + if (next) { + this.goToScene(next) + } else { + this.endGame() + } + return + } + } this.endGame() + } else { + const next = this.sceneManager.getScene(scene.nextScene) + if (next) { + this.goToScene(next) + } else { + this.endGame() + } } } else if (scene.hotspots?.length) { return diff --git a/engine/core/SceneManager.ts b/engine/core/SceneManager.ts index a982443..4b801de 100644 --- a/engine/core/SceneManager.ts +++ b/engine/core/SceneManager.ts @@ -50,8 +50,13 @@ export class SceneManager { } } - if (scene.nextScene && !targets.includes(scene.nextScene)) { - targets.push(scene.nextScene) + if (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 diff --git a/engine/types.ts b/engine/types.ts index e359d40..4ea5ce3 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -10,7 +10,7 @@ export interface SceneNode { choices?: Choice[] hotspots?: Hotspot[] qte?: QTEDefinition - nextScene?: string + nextScene?: string | Choice[] onEnter?: Effect[] loopStart?: number loopEnd?: number diff --git a/public/scenes/demo.json b/public/scenes/demo.json index 3ef592f..b62623f 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -549,6 +549,15 @@ }, "choices": [], "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" } + ] } } } \ No newline at end of file diff --git a/src/components/StoryGallery.vue b/src/components/StoryGallery.vue index 2d694aa..fa0e681 100644 --- a/src/components/StoryGallery.vue +++ b/src/components/StoryGallery.vue @@ -41,8 +41,16 @@ function collectReachable(startId: string, chapterId: string): Set { queue.push(c.targetScene) } } - if (scene.nextScene && !visited.has(scene.nextScene) && !isOtherChapterStart(scene.nextScene, chapterId)) - queue.push(scene.nextScene) + if (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.successScene && !visited.has(scene.qte.successScene) && !isOtherChapterStart(scene.qte.successScene, chapterId)) queue.push(scene.qte.successScene) @@ -157,7 +165,13 @@ function buildPlayerTree(sceneId: string, chapterId: string, depth: number, path if (scene.choices) { 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) { pushChild(scene.qte.successScene) pushChild(scene.qte.failScene)