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,设置面板显示画质下拉
|
- [ ] 验证:浏览器 `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
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) {
|
} 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user