From d2dae38f05473275847809d8adb8fc0f60b05f35 Mon Sep 17 00:00:00 2001 From: cocos02 Date: Wed, 10 Jun 2026 16:01:26 +0800 Subject: [PATCH] feat: P22 merge chapter select and gallery into StoryGallery, i18n updates --- ROADMAP.md | 52 ++-- src/App.vue | 65 ++--- src/components/ChapterSelect.vue | 209 ---------------- src/components/EndingGallery.vue | 154 ------------ src/components/MainMenu.vue | 12 +- src/components/StoryGallery.vue | 407 +++++++++++++++++++++++++++++++ src/locales/en.json | 1 + src/locales/zh.json | 1 + 8 files changed, 461 insertions(+), 440 deletions(-) delete mode 100644 src/components/ChapterSelect.vue delete mode 100644 src/components/EndingGallery.vue create mode 100644 src/components/StoryGallery.vue diff --git a/ROADMAP.md b/ROADMAP.md index fba33ce..4af7a5c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1140,9 +1140,16 @@ npx @electron/packager . MyGame --platform=win32 --arch=x64 --out=release - [x] `src/locales/zh.json` + `en.json` — 新增 8 个 PauseMenu 专用翻译 key - [x] 验证:TypeScript + Vite build 通过 -### P22 故事进度总览 — 章节选择 + 画廊合并(待实现) +### P22 故事进度总览 — 章节选择 + 画廊合并 ✅ 已完成 2026-06-10 -目标:对标 Detroit: Become Human,将章节选择和结局画廊合并为"故事进度总览"一张视图。 +目标:对标 Detroit: Become Human,将章节选择和结局画廊合并为一张"故事进度总览"视图。 + +**优化设计(两处改进):** + +| 优化 | 说明 | +|------|------| +| **A: 去掉 `chapterId`** | 结局归属哪章不用显式声明。StoryGallery 通过 BFS 判断 `ending.sceneId` 是否在章节可达范围内,自动推导。零类型改动 | +| **B: 内嵌回顾** | 点击章节卡片 → 卡片展开内嵌回顾区域(同面板内显示场景列表+进度条),不再弹出独立的 ChapterRecap 弹窗。避免主菜单→故事进度→章节回顾三层模态框 | **现状 vs 业界对比:** @@ -1152,12 +1159,7 @@ npx @electron/packager . MyGame --platform=win32 --arch=x64 --out=release | 结局画廊 | `EndingGallery.vue` — 独立缩略图网格 | 结局归属章节,不独立展示 | | 主菜单入口 | 两个按钮:"章节选择" + "画廊" | 一个按钮:"故事进度" | -没有任何产品把这两个功能做成独立界面。结局天然属于章节,合并后: -- 一张卡片 = 章节缩略图 + 完成度百分比 + 解锁结局标签 + 开始按钮 -- 减少操作步骤,一眼看完全局 -- 视觉层级匹配叙事层级 - -**合并后界面:** +任何产品都不会把这两个功能做成独立界面。合并后: ``` ┌──────────────────────────────────────────┐ @@ -1171,33 +1173,37 @@ npx @electron/packager . MyGame --platform=win32 --arch=x64 --out=release │ │ │ │ │ │ │ │ │ │ 结局: │ │ 结局: │ │ 结局: │ │ │ │ ✅ 信任 │ │ — │ │ ✅ 继续 │ │ -│ │ ✅ 独行 │ │ │ │ │ │ +│ │ 🔒 独行 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ [▶ 开始] │ │ [▶ 开始] │ │ [▶ 开始] │ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ -│ 点击卡片主体 → 章节回顾 (ChapterRecap) │ +│ 点击卡片主体 → 卡片展开内嵌回顾区域 │ │ 点击 ▶ 开始 → 进入该章节 │ │ [返回] │ └──────────────────────────────────────────┘ ``` -**数据依赖:** 无额外字段。`chapters` + `endings`(按 `chapterId` 归属)+ `visitedSceneIds` + BFS 完成度计算三条已足够。 +埋入展示后,点击卡片主体 → 卡片区域自动展开,内嵌显示 BFS 可达的场景列表(✅ visited / ⬜ unvisited + 条件提示),无需额外弹出 ChapterRecap 弹窗。 + +**数据依赖:** `chapters` + `endings`(按 BFS 自动推导归属) + `visitedSceneIds` + BFS 完成度计算。零额外数据字段。 **实现清单:** -- [ ] `src/components/StoryGallery.vue` — **新建** — 统一故事进度总览组件 - - 每章一张卡片:缩略图 + 完成度进度条 + 该章结局标签(✅/🔒) - - 点击卡片主体 → `emit('openRecap', chapterId)` - - 点击"▶ 开始"按钮 → `emit('startChapter', chapterId)` -- [ ] `src/App.vue` — 主菜单"章节选择" + "画廊"两按钮合并为"故事进度"一个按钮 - - 打开 StoryGallery,内部可触发 ChapterRecap 或 startChapter - - 删除 `showChapterSelect` / `showEndingGallery` 两个状态 - - 新增 `showStoryGallery` 一个状态 -- [ ] `src/components/ChapterSelect.vue` — **删除** -- [ ] `src/components/EndingGallery.vue` — **删除** -- [ ] `src/components/MainMenu.vue` — 移除 chapters/gallery 两个 emit,合并为一个 `story` emit -- [ ] 验证:TypeScript + Vite build 通过 +- [x] `src/components/StoryGallery.vue` — **新建** — 统一故事进度总览组件。BFS 自动推导结局归属;点击卡片内嵌展开场景列表 +- [x] `src/components/ChapterSelect.vue` — **删除** +- [x] `src/components/EndingGallery.vue` — **删除** +- [x] `src/components/ChapterRecap.vue` — 逻辑内嵌到 StoryGallery(场景列表 + 完成度 + 条件提示) +- [x] `src/components/MainMenu.vue` — chapters/gallery 两 emit 合并为 `story` emit +- [x] `src/App.vue` — 主菜单只显示"故事进度"一个按钮;删除旧两组件引用 +- [x] `src/locales/zh.json` + `en.json` — 新增 `story` key +- [x] 验证:TypeScript + Vite build 通过 +- [x] 验证:主菜单只显示一个"故事进度"按钮,不再有独立的"章节选择"和"画廊" +- [x] 验证:StoryGallery 中每章卡片正确显示完成度百分比 +- [x] 验证:结局标签按 BFS 自动归属到正确的章节卡片下(✅/🔒) +- [x] 验证:点击卡片主体 → 内嵌展开场景列表(✅ visited / ⬜ unvisited + 条件提示),不额外弹窗 +- [x] 验证:点击 ▶ 开始 → 正确跳转到该章节 +- [x] 验证:已删除 ChapterSelect.vue / EndingGallery.vue 后项目无编译残留 ```json { diff --git a/src/App.vue b/src/App.vue index eee005c..9888f3d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,15 +6,13 @@ import QTEOverlay from '@/components/QTEOverlay.vue' import Subtitles from '@/components/Subtitles.vue' import HotspotLayer from '@/components/HotspotLayer.vue' import SaveLoadMenu from '@/components/SaveLoadMenu.vue' -import ChapterSelect from '@/components/ChapterSelect.vue' import PlaybackBar from '@/components/PlaybackBar.vue' import MainMenu from '@/components/MainMenu.vue' import PauseMenu from '@/components/PauseMenu.vue' import AchievementToast from '@/components/AchievementToast.vue' import AchievementPanel from '@/components/AchievementPanel.vue' -import EndingGallery from '@/components/EndingGallery.vue' -import ChapterRecap from '@/components/ChapterRecap.vue' import AccessibilitySettings from '@/components/AccessibilitySettings.vue' +import StoryGallery from '@/components/StoryGallery.vue' import { useGameEngine } from '@/composables/useGameEngine' import { useGameStore } from '@/stores/gameStore' import { useFullscreen } from '@/composables/useFullscreen' @@ -29,10 +27,8 @@ const loading = ref(true) const started = ref(false) const showMenu = ref(false) const showPauseMenu = ref(false) -const showChapterSelect = ref(false) +const showStoryGallery = ref(false) const showAchievements = ref(false) -const showEndingGallery = ref(false) -const recapChapterId = ref(null) const hasAutoSave = ref(false) const currentSpeed = ref(1) const canSkip = ref(false) @@ -117,8 +113,8 @@ watch([() => store.qteTimeRelax, () => store.qteSingleKey], () => { watch( () => [ - showPauseMenu.value, showMenu.value, showChapterSelect.value, - showAchievements.value, showEndingGallery.value, recapChapterId.value, + showPauseMenu.value, showMenu.value, + showAchievements.value, showStoryGallery.value, paused.value, store.showSettings, ], () => { resetTopBarTimer() }, @@ -155,14 +151,9 @@ async function onLoad(slot: number) { showMenu.value = false } -function openChapterSelect() { - showMenu.value = false - showChapterSelect.value = true -} - async function onStartChapter(chapterId: string) { - showChapterSelect.value = false started.value = true + applyQteParams() startChapter(chapterId) } @@ -201,18 +192,14 @@ function onGlobalKeydown(e: KeyboardEvent) { if (key === 'Escape') { if (store.showSettings) { store.showSettings = false - } else if (showChapterSelect.value) { - showChapterSelect.value = false + } else if (showStoryGallery.value) { + showStoryGallery.value = false } else if (showMenu.value) { showMenu.value = false } else if (showPauseMenu.value) { showPauseMenu.value = false } else if (showAchievements.value) { showAchievements.value = false - } else if (showEndingGallery.value) { - showEndingGallery.value = false - } else if (recapChapterId.value) { - recapChapterId.value = null } else if (started.value && !store.gameEnded) { showPauseMenu.value = true } @@ -258,9 +245,8 @@ function onGlobalMouseMove() { } function anyOverlayOpen(): boolean { - return showPauseMenu.value || showMenu.value || showChapterSelect.value - || showAchievements.value || showEndingGallery.value || !!recapChapterId.value - || paused.value || store.showSettings + return showPauseMenu.value || showMenu.value || showStoryGallery.value + || showAchievements.value || paused.value || store.showSettings } function resetTopBarTimer() { @@ -346,15 +332,13 @@ init() - - - -import { ref, watch, nextTick } from 'vue' -import type { ChapterInfo } from '@engine/types' -import { useI18n } from '@/composables/useI18n' - -const props = defineProps<{ - chapters: ChapterInfo[] - unlockedIds: Set -}>() - -const emit = defineEmits<{ - select: [chapterId: string] - back: [] -}>() - -const { t } = useI18n() -const focusIdx = ref(0) -const cardRefs = ref<(HTMLDivElement | null)[]>([]) - -function setRef(el: HTMLDivElement | null, i: number) { - cardRefs.value[i] = el -} - -// when shown, focus first unlocked -watch(() => props.chapters.length, async (len) => { - if (len > 0) { - const first = props.chapters.findIndex(ch => props.unlockedIds.has(ch.id)) - focusIdx.value = first >= 0 ? first : 0 - await nextTick() - cardRefs.value[focusIdx.value]?.focus() - } -}) - -function onKeydown(e: KeyboardEvent, index: number) { - if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { - e.preventDefault() - const dir = e.key === 'ArrowRight' ? 1 : -1 - const len = props.chapters.length - let next = (index + dir + len) % len - // skip locked ones - let tries = 0 - while (!props.unlockedIds.has(props.chapters[next].id) && tries < len) { - next = (next + dir + len) % len - tries++ - } - focusIdx.value = next - cardRefs.value[next]?.focus() - } - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - if (props.unlockedIds.has(props.chapters[index].id)) { - emit('select', props.chapters[index].id) - } - } - if (e.key === 'Escape' || e.key === 'Backspace') { - e.preventDefault() - emit('back') - } -} - - - - - diff --git a/src/components/EndingGallery.vue b/src/components/EndingGallery.vue deleted file mode 100644 index 30f42a0..0000000 --- a/src/components/EndingGallery.vue +++ /dev/null @@ -1,154 +0,0 @@ - - - - - diff --git a/src/components/MainMenu.vue b/src/components/MainMenu.vue index d676d93..af7085d 100644 --- a/src/components/MainMenu.vue +++ b/src/components/MainMenu.vue @@ -3,18 +3,16 @@ import { useI18n } from '@/composables/useI18n' defineProps<{ showResume: boolean - showChapters: boolean + showStory: boolean showAchievements: boolean - showGallery: boolean isGameEnd: boolean }>() const emit = defineEmits<{ start: [] resume: [] - chapters: [] + story: [] achievements: [] - gallery: [] settings: [] }>() @@ -31,12 +29,10 @@ const { t } = useI18n() diff --git a/src/components/StoryGallery.vue b/src/components/StoryGallery.vue new file mode 100644 index 0000000..fa4f8a6 --- /dev/null +++ b/src/components/StoryGallery.vue @@ -0,0 +1,407 @@ + + + + + diff --git a/src/locales/en.json b/src/locales/en.json index 6819357..988b9fa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -25,6 +25,7 @@ "pauseHint": "Esc = Resume", "achievements": "Achievements", "gallery": "Gallery", + "story": "Story Progress", "noAutoSave": "No auto save yet", "autoSaveHint": "Game auto-saves to slot 0 at each scene change", "language": "Language", diff --git a/src/locales/zh.json b/src/locales/zh.json index a5202eb..d06fd54 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -25,6 +25,7 @@ "pauseHint": "Esc = 继续", "achievements": "成就", "gallery": "画廊", + "story": "故事进度", "noAutoSave": "暂无自动存档", "autoSaveHint": "游戏会在每次场景切换时自动保存到槽位 0", "language": "语言",