feat: choice conditions with variables, demo updates, roadmap update

This commit is contained in:
2026-06-09 16:46:06 +08:00
parent beb1e5cfd5
commit bf4b85f727
4 changed files with 155 additions and 59 deletions

View File

@@ -712,81 +712,117 @@ engine.on('choiceRequest', (choiceList) => {
--> -->
### P13 重玩驱动系统 — 成就 + 统计 + 结局画廊 + 关键选择提示(待实现) ### P13 关键选择提示 — 选前标识 + 选后浮现 ✅ 已完成 2026-06-09
目标:增加重玩驱动力。告知玩家"还有未发现的内容",激发探索欲望。涵盖 Telltale 的"[某人]会记住"、 目标:让玩家感知哪些选择有重量。Choice 增加 `prompt` 字段,
Quantic Dream 的结局流程图、Steam 式成就系统、通关后的全局统计 同时驱动前置金色标识Detroit 风格选前感知和后置文字浮现Telltale 风格,选后提示)
**子功能清单** **前后都做**
**13a. 关键选择提示:** - **前置 —** 有 `prompt` 的选项,按钮左边金色竖线 + 淡金边框,悬停发光。选前感知重要性。
- [ ] `Choice.prompt?: string` — 选择前弹出的提示文字(如 "[某人] 会记住你的选择" - **后置 —** 选择确认后,画面中央浮现 `prompt` 文字2 秒淡出。
- [ ] `src/components/ChoicePanel.vue` — 选项确认前展示提示动画(短暂放大/光效)
**13b. 成就系统:** ```json
- [ ] `engine/systems/AchievementSystem.ts` — 成就定义:`{ id, title, description, icon, condition(scene, state) }` {
- [ ] 成就触发检测(场景切换时匹配条件),解锁持久化到 IndexedDB "text": "与陌生人握手",
- [ ] `src/components/AchievementToast.vue` — 解锁时弹出提示(底部 toast + 音效) "prompt": "陌生人会记住你的善意",
- [ ] `src/components/AchievementPanel.vue` — 成就列表页面(全部/已解锁/未解锁) "targetScene": "trust_ending"
}
```
**13c. 结局画廊:** 一个字段驱动两个行为,零额外数据结构。
- [ ] `GameData.endings` — 结局定义:`{ id, label, thumbnail, unlockCondition }`
- [ ] `src/components/EndingGallery.vue` — 结局缩略图网格:已解锁显示画面,未解锁显示?剪影 + 提示
- [ ] 通关后自动跳转到结局画廊(或主菜单入口)
**13d. 全局统计** **实现清单**
- [ ] `engine/systems/StatsSystem.ts` — 计数:线索收集数、总死亡/失败次数、各结局达成率
- [ ] `src/components/StatsPanel.vue` — 通关后展示统计面板:"73% 的玩家选择了救人"、"你收集了 3/5 个线索"
- [ ] 注:全局百分比需要后端聚合数据(`/api/stats`),纯本地可展示个人计数
**13e. 章节剧情回顾:** - [x] `engine/types.ts``Choice.prompt?: string`
- [ ] `src/components/ChapterRecap.vue` — 章节结束后显示本分支的简化流程图(已走过的路径高亮) - [x] `src/components/ChoicePanel.vue``.has-prompt` CSS金色左边框 + 淡金边框 + 悬停发光)+ 选后 `prompt-toast` 浮现 2s 淡出
- [ ] 基于 SceneGraph 现有的 Vue Flow 节点图实现,只读模式 - [x] `public/scenes/demo.json` — left_door + trust_ending 各一个 prompt 示例
- [x] 验证TypeScript + Vite build 通过
**验证:** 成就解锁弹出 toast、结局画廊正确显示解锁状态、章节结束后显示回顾、统计面板数据正确 ### P14 成就系统 — 事件触发 + 条件检测 + Toast 弹窗(待实现)
目标Steam 式成就系统,驱动重玩探索。事件触发或变量条件检测,解锁时底部 toast 滑入。
### P14 沉浸感提升 — UI 反馈音效 + 对话轮 UI + 动态字幕(已废弃,功能拆分到其他 P 或远期) **数据结构:**
<!--
场景内音效由视频制作时混入,不做引擎级 SFX 事件系统。
UI 反馈音效/对话轮/动态字幕功能过于琐碎,拆分到远期规划。
- [x] ~~engine/systems/AudioSystem.ts 升级~~ ```json
- [x] ~~src/components/DialogueWheel.vue~~ {
- [x] ~~engine/types.ts subtitle cue 扩展~~ "achievements": [
- [x] ~~验证~~ {
"id": "qte_master",
"title": "反应达人",
"description": "成功完成一次 QTE",
"icon": "",
"hidden": false,
"trigger": { "event": "qteResult", "success": true }
},
{
"id": "explorer",
"title": "探索者",
"description": "搜索过房间的每一个角落",
"icon": "",
"hidden": false,
"trigger": { "variable": "investigation", "op": ">=", "value": 2 }
}
]
}
```
--> 触发条件支持:`{ event }`qteResult / choiceMade / gameEnd / sceneReached`{ variable, op, value }`
解锁持久化到 IndexedDBtoast 滑入 3 秒消失。
### P15 平台化 — 云存档 + 可访问性 + 自适应码率(待实现) **实现清单:**
目标:面向分发和用户多样性的补全功能。 - [ ] `engine/types.ts``GameData.achievements``AchievementDef` 接口
- [ ] `engine/systems/AchievementSystem.ts` — 触发检测 + 解锁 + 回调 onUnlock
- [ ] `engine/systems/SaveSystem.ts` — DB v5 新增 `achievements`
- [ ] `engine/core/Engine.ts` — 事件点调用 `achievementSystem.check(event, data)`
- [ ] `src/components/AchievementToast.vue` — 底部弹窗滑入/滑出动画
- [ ] `src/components/AchievementPanel.vue` — 成就列表(全部/已解锁/未解锁)
- [ ] `src/stores/gameStore.ts` — 成就解锁状态
- [ ] `src/App.vue` — 整合 AchievementToast + 主菜单"成就"入口
- [ ] `public/scenes/demo.json` — 2~3 个成就示例
- [ ] 验证QTE 成功触发 toast、变量成就自动检测、面板显示正确
**子功能清单:** ### P15 结局画廊 + 章节回顾 — 分支图可视化(待实现)
**15a. 云存档(需后端):** 目标:通关后展示结局画廊 + 章节分支流程图。玩家直观看到"哪些结局未解锁、哪些分支未走过",驱动重玩。
- [ ] 存档上传/下载 API 接口设计REST: `PUT /saves/:slot`, `GET /saves`
- [ ] `engine/systems/SaveSystem.ts` 升级 — save/load 支持 `remote: boolean` 参数
- [ ] 登录态管理(可选:不强制登录,本地存档为主,云存档为可选项)
**15b. 可访问性设置** **结局画廊**
- [ ] 字幕样式自定义:字体大小、背景透明度、颜色(白/黄/绿)
- [ ] 高对比度模式(`filter: contrast(1.3)` 全局应用)
- [ ] QTE 辅助模式:放宽时间限制、简化按键(如单键替代组合键)
- [ ] 色盲模式:选项颜色不依赖红/绿区分
- [ ] `src/components/AccessibilitySettings.vue` — 可访问性设置面板
**15c. 自适应码率:** ```json
- [ ] `engine/core/VideoManager.ts` — 支持 HLS`.m3u8`)和 DASH`.mpd`)流媒体源 {
- [ ] `SceneNode.videoUrl` 支持多码率:`{ auto: '/videos/hls/scene.m3u8', hd: '/videos/scene_1080p.mp4' }` "endings": [
- [ ] 网络质量检测(`navigator.connection` API自动降级 { "id": "trust_end", "label": "信任的伙伴", "sceneId": "trust_ending", "thumbnail": "/images/end_trust.jpg" },
{ "id": "alone_end", "label": "独行之路", "sceneId": "alone_ending", "thumbnail": "/images/end_alone.jpg" }
]
}
```
**15d. 其余补充:** 引擎 `goToScene` 到达结局场景时标记解锁。画廊缩略图网格:已解锁显示画面,未解锁 ? 剪影。
- [ ] 主菜单界面:新游戏 / 继续 / 章节选择 / 成就 / 结局画廊 / 设置
- [ ] 设置持久化到 localStorage语言、音量、可访问性偏好
- [ ] 导演评论音轨开关(`SceneNode.commentaryUrl?: string`,作为可选第二音轨)
**验证:** 云存档跨设备同步、字幕大小调整生效、码率自适应切换无卡顿 **章节回顾:**
基于 Vue Flow 节点图(复用 P3 编辑器),只读模式。追踪 `visitedSceneIds: Set<string>`
`goToScene` 中记录。展示章节分支图,已走路径高亮,未走路径灰色虚线。
入口:游戏结束后展示 + 章节选择界面每章卡片下有"回顾"按钮。不打断游戏流程。
**实现清单:**
- [ ] `engine/types.ts``GameData.endings``EndingDef`
- [ ] `engine/systems/SaveSystem.ts` — DB v5 新增 `endings` + `visited`
- [ ] `engine/core/Engine.ts``goToScene` 标记结局 + 记录 visited scene
- [ ] `src/components/EndingGallery.vue` — 结局缩略图网格 + 锁定/解锁
- [ ] `src/components/ChapterRecap.vue` — Vue Flow 只读分支图 + 路径高亮
- [ ] `src/stores/gameStore.ts` — 结局/visited 状态
- [ ] `src/App.vue` — 主菜单"画廊"入口 + 游戏结束整合回顾
- [ ] `public/scenes/demo.json` — endings 定义 + 结局缩略图
- [ ] 验证:结局到达→解锁→画廊显示、章节回顾路径高亮正确、未解锁结局 ? 剪影
### P16 平台化 — 云存档 + 可访问性 + 自适应码率 + 全局统计(待实现)
目标:面向分发和用户多样性的补全功能。含原 P15 + 原 P13d 全局统计。
## 依赖清单 ## 依赖清单

View File

@@ -24,6 +24,7 @@ export interface SceneNode {
export interface Choice { export interface Choice {
text: string text: string
textKey?: string textKey?: string
prompt?: string
targetScene: string targetScene: string
conditions?: Condition[] conditions?: Condition[]
effects?: Effect[] effects?: Effect[]

View File

@@ -146,6 +146,8 @@
"choices": [ "choices": [
{ {
"text": "与陌生人握手", "text": "与陌生人握手",
"textKey": "scene.left_door.choice.handshake",
"prompt": "陌生人会记住你的善意",
"targetScene": "trust_ending", "targetScene": "trust_ending",
"effects": [ "effects": [
{ "type": "add", "target": "trust", "value": 30 } { "type": "add", "target": "trust", "value": 30 }
@@ -238,6 +240,8 @@
"choices": [ "choices": [
{ {
"text": "开启信任的旅程(需要 trust >= 80", "text": "开启信任的旅程(需要 trust >= 80",
"textKey": "scene.trust_ending.choice.journey",
"prompt": "你们的羁绊将改变一切",
"targetScene": "secret_ending", "targetScene": "secret_ending",
"conditions": [ "conditions": [
{ "variable": "trust", "op": ">=", "value": 80 } { "variable": "trust", "op": ">=", "value": 80 }

View File

@@ -16,6 +16,8 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const focusIndex = ref(0) const focusIndex = ref(0)
const btnRefs = ref<(HTMLButtonElement | null)[]>([]) const btnRefs = ref<(HTMLButtonElement | null)[]>([])
const toastText = ref('')
const toastVisible = ref(false)
function timerPercent(): number { function timerPercent(): number {
if (props.timerTotal <= 0) return 0 if (props.timerTotal <= 0) return 0
@@ -49,9 +51,19 @@ function onKeydown(e: KeyboardEvent, index: number) {
} }
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
emit('choose', index) handleChoose(index)
} }
} }
function handleChoose(index: number) {
const choice = props.choices[index]
if (choice?.prompt) {
toastText.value = choice.prompt
toastVisible.value = true
setTimeout(() => { toastVisible.value = false }, 2200)
}
emit('choose', index)
}
</script> </script>
<template> <template>
@@ -70,14 +82,18 @@ function onKeydown(e: KeyboardEvent, index: number) {
v-for="(choice, index) in choices" v-for="(choice, index) in choices"
:key="index" :key="index"
:ref="(el: any) => setRef(el, index)" :ref="(el: any) => setRef(el, index)"
class="choice-btn" :class="['choice-btn', { 'has-prompt': !!choice.prompt }]"
tabindex="0" tabindex="0"
@click="emit('choose', index)" @click="handleChoose(index)"
@keydown="onKeydown($event, index)" @keydown="onKeydown($event, index)"
> >
{{ t(choice.textKey || choice.text) }} {{ t(choice.textKey || choice.text) }}
</button> </button>
</div> </div>
<Transition name="toast-fade">
<div class="prompt-toast" v-if="toastVisible">{{ toastText }}</div>
</Transition>
</div> </div>
</template> </template>
@@ -137,6 +153,7 @@ function onKeydown(e: KeyboardEvent, index: number) {
} }
.choice-btn { .choice-btn {
position: relative;
padding: 14px 24px; padding: 14px 24px;
font-size: 16px; font-size: 16px;
color: #fff; color: #fff;
@@ -144,7 +161,7 @@ function onKeydown(e: KeyboardEvent, index: number) {
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: background 0.2s, border-color 0.2s; transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
outline: none; outline: none;
} }
@@ -158,4 +175,42 @@ function onKeydown(e: KeyboardEvent, index: number) {
border-color: rgba(255, 255, 255, 0.6); border-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.3); box-shadow: 0 0 8px rgba(255, 255, 255, 0.3);
} }
.choice-btn.has-prompt {
border-left: 3px solid #ffc107;
border-color: rgba(255, 193, 7, 0.4);
border-left-color: #ffc107;
}
.choice-btn.has-prompt:hover {
border-color: rgba(255, 193, 7, 0.7);
box-shadow: 0 0 12px rgba(255, 193, 7, 0.25);
}
.choice-btn.has-prompt:focus-visible {
border-color: rgba(255, 193, 7, 0.7);
box-shadow: 0 0 12px rgba(255, 193, 7, 0.3);
}
.prompt-toast {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 14px 32px;
font-size: 18px;
color: #ffc107;
background: rgba(0, 0, 0, 0.85);
border: 1px solid rgba(255, 193, 7, 0.4);
border-radius: 6px;
letter-spacing: 2px;
text-align: center;
white-space: nowrap;
pointer-events: none;
}
.toast-fade-enter-active { transition: opacity 0.3s ease; }
.toast-fade-leave-active { transition: opacity 0.6s ease; }
.toast-fade-enter-from,
.toast-fade-leave-to { opacity: 0; }
</style> </style>