feat: P25 conditional routing, nextScene supports Choice[] with conditions
This commit is contained in:
48
ROADMAP.md
48
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)。
|
||||
|
||||
7
docs/使用经验.md
Normal file
7
docs/使用经验.md
Normal file
@@ -0,0 +1,7 @@
|
||||
带选择的场景,用单独的一个场景和视频表示。配置循环0.1到视频长度-0.1.效果就是进入视频就会出现选择而且还能循环播放视频
|
||||
|
||||
|
||||
用户进攻是否成功由qte决定 用户防守是否成功由qte决定
|
||||
战斗 |<- 用户进攻回合 用户进攻回合 ->| |<- 用户防守回合 用户防守回合 ->|
|
||||
ready -> 用户选择进攻qte -> qte_success -> 播放玩家进攻敌人不防守动画 -> 用户选择防守qte -> qte_sucess -> 用户防守敌人进攻 -> 回到 用户选择进攻qte节点
|
||||
-> qte_fail -> 播放玩家进攻敌人防守动画 -> 用户选择防守qte -> qte_fail -> 用户不防守敌人进攻 ->
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface SceneNode {
|
||||
choices?: Choice[]
|
||||
hotspots?: Hotspot[]
|
||||
qte?: QTEDefinition
|
||||
nextScene?: string
|
||||
nextScene?: string | Choice[]
|
||||
onEnter?: Effect[]
|
||||
loopStart?: number
|
||||
loopEnd?: number
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,16 @@ function collectReachable(startId: string, chapterId: string): Set<string> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user