feat: choice conditions with variables, demo updates, roadmap update
This commit is contained in:
146
ROADMAP.md
146
ROADMAP.md
@@ -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 }`。
|
||||||
|
解锁持久化到 IndexedDB,toast 滑入 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 全局统计。
|
||||||
|
|
||||||
## 依赖清单
|
## 依赖清单
|
||||||
|
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user