diff --git a/ROADMAP.md b/ROADMAP.md index 25f0d8e..83baa2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1003,6 +1003,65 @@ npx @electron/packager . MyGame --platform=win32 --arch=x64 --out=release - [x] 删除 `moviegame-starter` 目录 - [x] 验证:`pack:html` 生成 release/mygame.zip +### P20 开场流程 — 启动视频 + 菜单背景视频 ✅ 已完成 2026-06-10 + +目标:对标行业标准,游戏启动时先播放开场视频,结束后淡入主菜单。 +菜单背景支持循环视频(如 Detroit 飘雪的城市夜景),按钮叠加其上。 + +**设计决策:** + +| 决策 | 做法 | +|------|------| +| **跳过逻辑** | 复用 P9 `watched` 表。开场视频用虚拟场景 ID `__intro__`。首次播放不可跳过,播放结束后 `markWatched('__intro__')`。后续启动 `isWatched('__intro__')` 为 true → 显示跳过按钮 | +| **跳过行为** | 和 P9 一致 — 停止视频 → 进入菜单 | +| **菜单背景** | `menuVideo` 循环播放,`MainMenu` 叠加其上 | +| **每次启动都播** | 不区分首次/再次,由 P9 跳过逻辑控制是否可跳 | + +**场景数据设计:** + +```json +{ + "startScene": "intro", + "introVideo": "/videos/studio_logo.mp4", + "menuVideo": "/videos/menu_bg.mp4", + "scenes": { ... } +} +``` + +**工作流:** + +``` +打开页面 + ├── 有 introVideo → 全屏播开场视频 + │ ├── watched? → 显示跳过按钮(和 P9 完全一致的交互) + │ └── ended / skip → markWatched('__intro__') → 进入菜单 + └── 无 introVideo → 直接显示菜单 + +主菜单 + ├── 有 menuVideo → 背景循环播 menuVideo + │ ├── [开始游戏] [继续] ... 按钮叠加在视频上 + │ ├── 开始游戏 → 停止 menuVideo → 进入第一场景 + │ └── 游戏结束 → 恢复 menuVideo 循环 + └── 无 menuVideo → 纯黑背景 +``` + +**实现改动:** + +| 文件 | 改动 | +|------|------| +| `engine/types.ts` | `GameData` 加 `introVideo?: string`、`menuVideo?: string` | +| `src/App.vue` | 加载后判断 `introVideo` → 播开场 → ended/跳过 → `MainMenu`;`MainMenu` 背景用 `menuVideo` 循环 | + +**实现清单:** + +- [x] `engine/types.ts` — `GameData.introVideo?` / `GameData.menuVideo?` +- [x] `src/composables/useGameEngine.ts` — `applyAssetBase` 处理 introVideo/menuVideo;`loadGame` 写入 store +- [x] `src/stores/gameStore.ts` — `introVideo` / `menuVideo` 状态 + setter +- [x] `src/App.vue` — 开场视频全屏播放 + P9 跳过逻辑(`__intro__` watched)+ 菜单背景视频循环 +- [x] `public/demo/videos/intro_logo.mp4` + `menu_bg.mp4` — 示例视频 +- [x] `public/scenes/demo.json` — 配置 `introVideo` / `menuVideo` +- [x] 验证:TypeScript + Vite build 通过 + ## 依赖清单 ```json diff --git a/engine/types.ts b/engine/types.ts index d9af853..0f0e46b 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -111,6 +111,8 @@ export interface GameData { chapters?: ChapterInfo[] achievements?: AchievementDef[] endings?: EndingDef[] + introVideo?: string + menuVideo?: string } export interface ChoiceRecord { diff --git a/public/demo/__intro__/intro_logo.mp4 b/public/demo/__intro__/intro_logo.mp4 new file mode 100644 index 0000000..8371b8a Binary files /dev/null and b/public/demo/__intro__/intro_logo.mp4 differ diff --git a/public/demo/__intro__/menu_bg.mp4 b/public/demo/__intro__/menu_bg.mp4 new file mode 100644 index 0000000..d2a4bb5 Binary files /dev/null and b/public/demo/__intro__/menu_bg.mp4 differ diff --git a/public/scenes/demo.json b/public/scenes/demo.json index 6f53e8a..3c630c2 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -10,6 +10,8 @@ "courage": 0, "investigation": 0 }, + "introVideo": "__intro__/intro_logo.mp4", + "menuVideo": "__intro__/menu_bg.mp4", "achievements": [ { "id": "qte_master", diff --git a/src/App.vue b/src/App.vue index 6e36320..5ffda57 100644 --- a/src/App.vue +++ b/src/App.vue @@ -37,6 +37,10 @@ const canSkip = ref(false) const paused = ref(false) const promptToast = ref('') const showPromptToast = ref(false) +const showIntro = ref(false) +const introWatched = ref(false) +const introVideoRef = ref(null) +const menuVideoRef = ref(null) const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, startChapter, skipScene, setSpeed, getSpeed, isSceneWatched, @@ -68,6 +72,21 @@ async function init() { } loading.value = false hasAutoSave.value = (await saveSystem.load(0)) !== null + + if (store.introVideo) { + introWatched.value = await isSceneWatched('__intro__') + showIntro.value = true + } +} + +function onIntroEnded() { + saveSystem.markWatched('__intro__') + showIntro.value = false +} + +function skipIntro() { + saveSystem.markWatched('__intro__') + showIntro.value = false } function handleStart() { @@ -212,7 +231,16 @@ init()
{{ t('ui.loading') }}