feat: intro video, asset updates, roadmap and engine improvements
This commit is contained in:
59
ROADMAP.md
59
ROADMAP.md
@@ -1003,6 +1003,65 @@ npx @electron/packager . MyGame --platform=win32 --arch=x64 --out=release
|
|||||||
- [x] 删除 `moviegame-starter` 目录
|
- [x] 删除 `moviegame-starter` 目录
|
||||||
- [x] 验证:`pack:html` 生成 release/mygame.zip
|
- [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
|
```json
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export interface GameData {
|
|||||||
chapters?: ChapterInfo[]
|
chapters?: ChapterInfo[]
|
||||||
achievements?: AchievementDef[]
|
achievements?: AchievementDef[]
|
||||||
endings?: EndingDef[]
|
endings?: EndingDef[]
|
||||||
|
introVideo?: string
|
||||||
|
menuVideo?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChoiceRecord {
|
export interface ChoiceRecord {
|
||||||
|
|||||||
BIN
public/demo/__intro__/intro_logo.mp4
Normal file
BIN
public/demo/__intro__/intro_logo.mp4
Normal file
Binary file not shown.
BIN
public/demo/__intro__/menu_bg.mp4
Normal file
BIN
public/demo/__intro__/menu_bg.mp4
Normal file
Binary file not shown.
@@ -10,6 +10,8 @@
|
|||||||
"courage": 0,
|
"courage": 0,
|
||||||
"investigation": 0
|
"investigation": 0
|
||||||
},
|
},
|
||||||
|
"introVideo": "__intro__/intro_logo.mp4",
|
||||||
|
"menuVideo": "__intro__/menu_bg.mp4",
|
||||||
"achievements": [
|
"achievements": [
|
||||||
{
|
{
|
||||||
"id": "qte_master",
|
"id": "qte_master",
|
||||||
|
|||||||
78
src/App.vue
78
src/App.vue
@@ -37,6 +37,10 @@ const canSkip = ref(false)
|
|||||||
const paused = ref(false)
|
const paused = ref(false)
|
||||||
const promptToast = ref('')
|
const promptToast = ref('')
|
||||||
const showPromptToast = ref(false)
|
const showPromptToast = ref(false)
|
||||||
|
const showIntro = ref(false)
|
||||||
|
const introWatched = ref(false)
|
||||||
|
const introVideoRef = ref<HTMLVideoElement | null>(null)
|
||||||
|
const menuVideoRef = ref<HTMLVideoElement | null>(null)
|
||||||
|
|
||||||
const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, startChapter,
|
const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, startChapter,
|
||||||
skipScene, setSpeed, getSpeed, isSceneWatched,
|
skipScene, setSpeed, getSpeed, isSceneWatched,
|
||||||
@@ -68,6 +72,21 @@ async function init() {
|
|||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
hasAutoSave.value = (await saveSystem.load(0)) !== null
|
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() {
|
function handleStart() {
|
||||||
@@ -212,7 +231,16 @@ init()
|
|||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<div v-if="loading" class="loading">{{ t('ui.loading') }}</div>
|
<div v-if="loading" class="loading">{{ t('ui.loading') }}</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="game-screen">
|
<div v-if="showIntro" class="intro-overlay" @click="skipIntro">
|
||||||
|
<video ref="introVideoRef" :src="store.introVideo" class="intro-video" autoplay @ended="onIntroEnded"></video>
|
||||||
|
<button v-if="introWatched" class="intro-skip-btn" @click.stop="skipIntro">{{ t('ui.skip') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="store.menuVideo && (!started || store.gameEnded)" class="menu-bg">
|
||||||
|
<video ref="menuVideoRef" :src="store.menuVideo" class="menu-bg-video" autoplay loop muted></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-screen" v-show="started && !store.gameEnded">
|
||||||
<GamePlayer v-show="!store.isImageScene" @video-ready="onVideoReady" />
|
<GamePlayer v-show="!store.isImageScene" @video-ready="onVideoReady" />
|
||||||
<HotspotLayer
|
<HotspotLayer
|
||||||
:hotspots="store.hotspots"
|
:hotspots="store.hotspots"
|
||||||
@@ -418,6 +446,54 @@ html, body {
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.intro-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #000;
|
||||||
|
z-index: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-skip-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px;
|
||||||
|
right: 40px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-skip-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-bg-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.prompt-toast {
|
.prompt-toast {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
|||||||
if (c.thumbnail) c.thumbnail = resolveAsset(base, c.thumbnail)
|
if (c.thumbnail) c.thumbnail = resolveAsset(base, c.thumbnail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (data.introVideo) data.introVideo = resolveAsset(base, data.introVideo)
|
||||||
|
if (data.menuVideo) data.menuVideo = resolveAsset(base, data.menuVideo)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGame(dataUrl: string) {
|
async function loadGame(dataUrl: string) {
|
||||||
@@ -165,6 +167,9 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
|||||||
|
|
||||||
const visitedIds = await saveSystem.getVisitedSceneIds()
|
const visitedIds = await saveSystem.getVisitedSceneIds()
|
||||||
store.setVisitedSceneIds(visitedIds)
|
store.setVisitedSceneIds(visitedIds)
|
||||||
|
|
||||||
|
store.setIntroVideo(data.introVideo || '')
|
||||||
|
store.setMenuVideo(data.menuVideo || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureVideo() {
|
function ensureVideo() {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
const antiMistap = ref(localStorage.getItem('antiMistap') !== 'false')
|
const antiMistap = ref(localStorage.getItem('antiMistap') !== 'false')
|
||||||
const pauseEnabled = ref(localStorage.getItem('pauseEnabled') !== 'false')
|
const pauseEnabled = ref(localStorage.getItem('pauseEnabled') !== 'false')
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
const introVideo = ref('')
|
||||||
|
const menuVideo = ref('')
|
||||||
|
|
||||||
function setScene(scene: SceneNode) {
|
function setScene(scene: SceneNode) {
|
||||||
currentScene.value = scene
|
currentScene.value = scene
|
||||||
@@ -197,6 +199,9 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
function setPauseEnabled(v: boolean) { pauseEnabled.value = v; localStorage.setItem('pauseEnabled', String(v)) }
|
function setPauseEnabled(v: boolean) { pauseEnabled.value = v; localStorage.setItem('pauseEnabled', String(v)) }
|
||||||
function setShowSettings(v: boolean) { showSettings.value = v }
|
function setShowSettings(v: boolean) { showSettings.value = v }
|
||||||
|
|
||||||
|
function setIntroVideo(url: string) { introVideo.value = url }
|
||||||
|
function setMenuVideo(url: string) { menuVideo.value = url }
|
||||||
|
|
||||||
function dump() {
|
function dump() {
|
||||||
console.group('GameStore')
|
console.group('GameStore')
|
||||||
console.log('currentScene:', currentScene.value?.id)
|
console.log('currentScene:', currentScene.value?.id)
|
||||||
@@ -217,7 +222,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
toastAchievementId, showEndingGallery, endings, visitedSceneIds,
|
toastAchievementId, showEndingGallery, endings, visitedSceneIds,
|
||||||
storyLocales,
|
storyLocales,
|
||||||
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
|
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
|
||||||
showSettings,
|
showSettings, introVideo, menuVideo,
|
||||||
setScene, setChoices, clearChoices, setGameEnded,
|
setScene, setChoices, clearChoices, setGameEnded,
|
||||||
setTimer, clearTimer, setSaves,
|
setTimer, clearTimer, setSaves,
|
||||||
showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime,
|
showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime,
|
||||||
@@ -229,7 +234,7 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
setEndings, setShowEndingGallery, setVisitedSceneIds, addVisitedSceneId,
|
setEndings, setShowEndingGallery, setVisitedSceneIds, addVisitedSceneId,
|
||||||
setStoryLocales,
|
setStoryLocales,
|
||||||
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
|
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
|
||||||
setShowSettings,
|
setShowSettings, setIntroVideo, setMenuVideo,
|
||||||
dump,
|
dump,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user