diff --git a/ROADMAP.md b/ROADMAP.md index 3e051f5..5f881a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -712,81 +712,117 @@ engine.on('choiceRequest', (choiceList) => { --> -### P13 重玩驱动系统 — 成就 + 统计 + 结局画廊 + 关键选择提示(待实现) +### P13 关键选择提示 — 选前标识 + 选后浮现 ✅ 已完成 2026-06-09 -目标:增加重玩驱动力。告知玩家"还有未发现的内容",激发探索欲望。涵盖 Telltale 的"[某人]会记住"、 -Quantic Dream 的结局流程图、Steam 式成就系统、通关后的全局统计。 +目标:让玩家感知哪些选择有重量。Choice 增加 `prompt` 字段, +同时驱动前置金色标识(Detroit 风格,选前感知)和后置文字浮现(Telltale 风格,选后提示)。 -**子功能清单:** +**前后都做:** -**13a. 关键选择提示:** -- [ ] `Choice.prompt?: string` — 选择前弹出的提示文字(如 "[某人] 会记住你的选择") -- [ ] `src/components/ChoicePanel.vue` — 选项确认前展示提示动画(短暂放大/光效) +- **前置 —** 有 `prompt` 的选项,按钮左边金色竖线 + 淡金边框,悬停发光。选前感知重要性。 +- **后置 —** 选择确认后,画面中央浮现 `prompt` 文字,2 秒淡出。 -**13b. 成就系统:** -- [ ] `engine/systems/AchievementSystem.ts` — 成就定义:`{ id, title, description, icon, condition(scene, state) }` -- [ ] 成就触发检测(场景切换时匹配条件),解锁持久化到 IndexedDB -- [ ] `src/components/AchievementToast.vue` — 解锁时弹出提示(底部 toast + 音效) -- [ ] `src/components/AchievementPanel.vue` — 成就列表页面(全部/已解锁/未解锁) +```json +{ + "text": "与陌生人握手", + "prompt": "陌生人会记住你的善意", + "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. 章节剧情回顾:** -- [ ] `src/components/ChapterRecap.vue` — 章节结束后显示本分支的简化流程图(已走过的路径高亮) -- [ ] 基于 SceneGraph 现有的 Vue Flow 节点图实现,只读模式 +- [x] `engine/types.ts` — `Choice.prompt?: string` +- [x] `src/components/ChoicePanel.vue` — `.has-prompt` CSS(金色左边框 + 淡金边框 + 悬停发光)+ 选后 `prompt-toast` 浮现 2s 淡出 +- [x] `public/scenes/demo.json` — left_door + trust_ending 各一个 prompt 示例 +- [x] 验证:TypeScript + Vite build 通过 -**验证:** 成就解锁弹出 toast、结局画廊正确显示解锁状态、章节结束后显示回顾、统计面板数据正确 +### P14 成就系统 — 事件触发 + 条件检测 + Toast 弹窗(待实现) +目标:Steam 式成就系统,驱动重玩探索。事件触发或变量条件检测,解锁时底部 toast 滑入。 -### P14 沉浸感提升 — UI 反馈音效 + 对话轮 UI + 动态字幕(已废弃,功能拆分到其他 P 或远期) - +触发条件支持:`{ 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. 自适应码率:** -- [ ] `engine/core/VideoManager.ts` — 支持 HLS(`.m3u8`)和 DASH(`.mpd`)流媒体源 -- [ ] `SceneNode.videoUrl` 支持多码率:`{ auto: '/videos/hls/scene.m3u8', hd: '/videos/scene_1080p.mp4' }` -- [ ] 网络质量检测(`navigator.connection` API),自动降级 +```json +{ + "endings": [ + { "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. 其余补充:** -- [ ] 主菜单界面:新游戏 / 继续 / 章节选择 / 成就 / 结局画廊 / 设置 -- [ ] 设置持久化到 localStorage(语言、音量、可访问性偏好) -- [ ] 导演评论音轨开关(`SceneNode.commentaryUrl?: string`,作为可选第二音轨) +引擎 `goToScene` 到达结局场景时标记解锁。画廊缩略图网格:已解锁显示画面,未解锁 ? 剪影。 -**验证:** 云存档跨设备同步、字幕大小调整生效、码率自适应切换无卡顿 +**章节回顾:** + +基于 Vue Flow 节点图(复用 P3 编辑器),只读模式。追踪 `visitedSceneIds: Set`, +在 `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 全局统计。 ## 依赖清单 diff --git a/engine/types.ts b/engine/types.ts index 3634474..a1c0445 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -24,6 +24,7 @@ export interface SceneNode { export interface Choice { text: string textKey?: string + prompt?: string targetScene: string conditions?: Condition[] effects?: Effect[] diff --git a/public/scenes/demo.json b/public/scenes/demo.json index 8640120..dec2c82 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -146,6 +146,8 @@ "choices": [ { "text": "与陌生人握手", + "textKey": "scene.left_door.choice.handshake", + "prompt": "陌生人会记住你的善意", "targetScene": "trust_ending", "effects": [ { "type": "add", "target": "trust", "value": 30 } @@ -238,6 +240,8 @@ "choices": [ { "text": "开启信任的旅程(需要 trust >= 80)", + "textKey": "scene.trust_ending.choice.journey", + "prompt": "你们的羁绊将改变一切", "targetScene": "secret_ending", "conditions": [ { "variable": "trust", "op": ">=", "value": 80 } diff --git a/src/components/ChoicePanel.vue b/src/components/ChoicePanel.vue index 81c4b16..216f79b 100644 --- a/src/components/ChoicePanel.vue +++ b/src/components/ChoicePanel.vue @@ -16,6 +16,8 @@ const emit = defineEmits<{ const { t } = useI18n() const focusIndex = ref(0) const btnRefs = ref<(HTMLButtonElement | null)[]>([]) +const toastText = ref('') +const toastVisible = ref(false) function timerPercent(): number { if (props.timerTotal <= 0) return 0 @@ -49,9 +51,19 @@ function onKeydown(e: KeyboardEvent, index: number) { } if (e.key === 'Enter' || e.key === ' ') { 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) +} @@ -137,6 +153,7 @@ function onKeydown(e: KeyboardEvent, index: number) { } .choice-btn { + position: relative; padding: 14px 24px; font-size: 16px; color: #fff; @@ -144,7 +161,7 @@ function onKeydown(e: KeyboardEvent, index: number) { border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; cursor: pointer; - transition: background 0.2s, border-color 0.2s; + transition: background 0.2s, border-color 0.2s, box-shadow 0.2s; outline: none; } @@ -158,4 +175,42 @@ function onKeydown(e: KeyboardEvent, index: number) { border-color: rgba(255, 255, 255, 0.6); 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; }