init
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
199
ROADMAP.md
Normal file
199
ROADMAP.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# 交互式电影游戏引擎 — Roadmap
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **框架**: Vue 3 (Composition API + `<script setup>`)
|
||||||
|
- **构建**: Vite
|
||||||
|
- **状态管理**: Pinia
|
||||||
|
- **可视化编辑器**: Vue Flow
|
||||||
|
- **存储**: IndexedDB (Dexie.js)
|
||||||
|
- **语言**: TypeScript
|
||||||
|
- **视频**: 原生 `<video>` + A/B 双缓冲
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
moviegame/
|
||||||
|
├── engine/ # 框架无关的核心引擎(纯 TS)
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── Engine.ts # 主循环,驱动各子系统
|
||||||
|
│ │ ├── SceneManager.ts # 剧情节点图遍历
|
||||||
|
│ │ ├── VideoManager.ts # A/B 双缓冲视频播放
|
||||||
|
│ │ └── StateManager.ts # 全局状态、条件求值
|
||||||
|
│ ├── systems/
|
||||||
|
│ │ ├── ChoiceSystem.ts # 选择 UI + 倒计时
|
||||||
|
│ │ ├── QTESystem.ts # QTE 触发、判定、超时
|
||||||
|
│ │ └── SaveSystem.ts # IndexedDB 存取
|
||||||
|
│ └── types.ts # 场景数据类型定义
|
||||||
|
├── src/ # Vue 应用(播放器端)
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── GamePlayer.vue # 主播放器(挂载 video 元素)
|
||||||
|
│ │ ├── ChoicePanel.vue # 选项面板
|
||||||
|
│ │ ├── QTEOverlay.vue # QTE 遮罩
|
||||||
|
│ │ ├── SaveLoadMenu.vue # 存档界面
|
||||||
|
│ │ └── Subtitles.vue # 字幕显示
|
||||||
|
│ ├── composables/
|
||||||
|
│ │ ├── useGameEngine.ts # 引擎实例 + 生命周期
|
||||||
|
│ │ ├── useVideoPlayer.ts # 视频状态响应式封装
|
||||||
|
│ │ └── useGameState.ts # 状态响应式封装
|
||||||
|
│ ├── stores/
|
||||||
|
│ │ └── gameStore.ts # 游戏全局状态(Pinia)
|
||||||
|
│ ├── App.vue
|
||||||
|
│ └── main.ts
|
||||||
|
├── editor/ # Vue Flow 可视化编辑器(独立入口)
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── SceneGraph.vue
|
||||||
|
│ │ ├── NodeEditor.vue
|
||||||
|
│ │ └── PreviewPanel.vue
|
||||||
|
│ ├── composables/
|
||||||
|
│ │ └── useGraphEditor.ts
|
||||||
|
│ └── App.vue
|
||||||
|
├── public/
|
||||||
|
│ ├── videos/ # 视频资源
|
||||||
|
│ └── scenes/
|
||||||
|
│ └── demo.json # 示例剧情数据
|
||||||
|
├── vite.config.ts
|
||||||
|
├── tsconfig.json
|
||||||
|
├── package.json
|
||||||
|
└── index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## 场景数据格式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// engine/types.ts
|
||||||
|
|
||||||
|
interface SceneNode {
|
||||||
|
id: string;
|
||||||
|
videoUrl: string;
|
||||||
|
subtitleUrl?: string;
|
||||||
|
choices?: Choice[];
|
||||||
|
qte?: QTEDefinition;
|
||||||
|
nextScene?: string;
|
||||||
|
onEnter?: Effect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Choice {
|
||||||
|
text: string;
|
||||||
|
targetScene: string;
|
||||||
|
conditions?: Condition[];
|
||||||
|
effects?: Effect[];
|
||||||
|
timeLimit?: number; // 限时选择(秒),0=不限时
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Condition {
|
||||||
|
variable: string;
|
||||||
|
op: '>' | '<' | '>=' | '<=' | '==' | '!=' | 'hasFlag';
|
||||||
|
value: number | string | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Effect {
|
||||||
|
type: 'set' | 'add' | 'toggleFlag' | 'triggerEvent';
|
||||||
|
target: string;
|
||||||
|
value?: number | string | boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QTEDefinition {
|
||||||
|
triggerTime: number;
|
||||||
|
prompt: string;
|
||||||
|
keys: string[];
|
||||||
|
timeLimit: number;
|
||||||
|
successScene: string;
|
||||||
|
failScene: string;
|
||||||
|
effects?: {
|
||||||
|
success: Effect[];
|
||||||
|
fail: Effect[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameData {
|
||||||
|
scenes: Record<string, SceneNode>;
|
||||||
|
startScene: string;
|
||||||
|
variables: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaveData {
|
||||||
|
slot: number;
|
||||||
|
timestamp: number;
|
||||||
|
currentScene: string;
|
||||||
|
variables: Record<string, number>;
|
||||||
|
flags: string[];
|
||||||
|
history: ChoiceRecord[];
|
||||||
|
thumbnail?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现路线
|
||||||
|
|
||||||
|
### P0 MVP — 最小可玩原型(3-5 天)✅ 已完成 2026-06-07
|
||||||
|
|
||||||
|
目标:能播放一段视频 → 弹出选项 → 跳到下一段视频
|
||||||
|
|
||||||
|
- [x] 项目脚手架:Vite + Vue3 + TypeScript + Pinia
|
||||||
|
- [x] `engine/core/Engine.ts` — 主循环骨架(加载场景 → 播放 → 等选择 → 切换)
|
||||||
|
- [x] `engine/core/SceneManager.ts` — 加载 JSON,按 ID 查找场景节点
|
||||||
|
- [x] `engine/core/VideoManager.ts` — 单 video 元素播放,监听 ended 事件
|
||||||
|
- [x] `engine/core/StateManager.ts` — 变量存取、条件求值、效果执行
|
||||||
|
- [x] `engine/types.ts` — 类型定义
|
||||||
|
- [x] `src/components/GamePlayer.vue` — 挂载 video,控制播放
|
||||||
|
- [x] `src/components/ChoicePanel.vue` — 渲染选择按钮,触发引擎切换
|
||||||
|
- [x] `public/scenes/demo.json` — 编写一段简单剧情(7 个场景节点)
|
||||||
|
- [x] 验证:从 demo.json 加载场景,能走通 开始→选择→分支播放→结束 流程
|
||||||
|
|
||||||
|
### P1 核心 — 无缝切换 + 条件分支 + 存档(1-2 周)
|
||||||
|
|
||||||
|
- [ ] `engine/core/VideoManager.ts` 升级 — A/B 双缓冲,预加载候选视频,CSS 交叉淡化
|
||||||
|
- [ ] `engine/core/SceneManager.ts` 升级 — 支持条件分支(根据 variables/flags 过滤选项)
|
||||||
|
- [ ] `engine/systems/SaveSystem.ts` — Dexie.js IndexedDB 存取,多槽位
|
||||||
|
- [ ] `engine/systems/ChoiceSystem.ts` — 限时选择倒计时,超时默认选择(第一项或配置的默认项)
|
||||||
|
- [ ] `src/components/SaveLoadMenu.vue` — 存档/读档 UI
|
||||||
|
- [ ] `src/stores/gameStore.ts` — Pinia 全局状态管理
|
||||||
|
- [ ] `src/composables/` — 三个 composable 桥接层
|
||||||
|
- [ ] 验证:分支剧情走通,存档读档正常,视频切换无明显黑屏
|
||||||
|
|
||||||
|
### P2 进阶 — QTE + 字幕 + 多存档槽(1 周)
|
||||||
|
|
||||||
|
- [ ] `engine/systems/QTESystem.ts` — QTE 触发、键盘监听、超时判定
|
||||||
|
- [ ] `src/components/QTEOverlay.vue` — QTE 视觉遮罩(按键提示 + 倒计时环)
|
||||||
|
- [ ] `src/components/Subtitles.vue` — WebVTT 解析 + 字幕渲染
|
||||||
|
- [ ] 多存档槽位 + 存档缩略图(canvas 截图当前视频帧)
|
||||||
|
- [ ] `engine/core/Engine.ts` — 完整事件总线(sceneChange, choiceMade, qteTriggered 等)
|
||||||
|
- [ ] 验证:QTE 正常触发与判定,字幕同步,多存档正常工作
|
||||||
|
|
||||||
|
### P3 编辑器 — 可视化剧情编辑(2-3 周)
|
||||||
|
|
||||||
|
- [ ] 编辑器入口:独立 `editor/index.html` + `editor/main.ts`
|
||||||
|
- [ ] `editor/components/SceneGraph.vue` — Vue Flow 节点图(节点=场景,边=选择分支)
|
||||||
|
- [ ] `editor/components/NodeEditor.vue` — 右侧面板,编辑选中节点的视频、选项、QTE、条件/效果
|
||||||
|
- [ ] `editor/components/PreviewPanel.vue` — 嵌入播放器,实时预览当前编辑的剧情线
|
||||||
|
- [ ] `editor/composables/useGraphEditor.ts` — 图数据与 JSON 双向同步
|
||||||
|
- [ ] JSON 导出/导入
|
||||||
|
- [ ] 验证:编辑器能产出合法 JSON,引擎能正确加载并运行
|
||||||
|
|
||||||
|
## 依赖清单
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4",
|
||||||
|
"pinia": "^2.1",
|
||||||
|
"@vue-flow/core": "^1.x",
|
||||||
|
"@vue-flow/background": "^1.x",
|
||||||
|
"@vue-flow/controls": "^1.x",
|
||||||
|
"dexie": "^4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0",
|
||||||
|
"typescript": "^5.3",
|
||||||
|
"vite": "^5.0",
|
||||||
|
"vue-tsc": "^2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键架构决策记录
|
||||||
|
|
||||||
|
1. **引擎与 UI 分离**: `engine/` 下纯 TS 类,不 import Vue。UI 层通过 composables 桥接。
|
||||||
|
2. **A/B 双缓冲**: 两个 `<video>` 元素轮换,一个播放时另一个预加载候选视频。
|
||||||
|
3. **JSON 驱动**: 所有剧情数据放在 JSON 中,编辑器本质是 JSON 的可视化读写工具。
|
||||||
|
4. **IndexedDB 存档**: 比 localStorage 容量大,可存储截屏缩略图。
|
||||||
103
engine/core/Engine.ts
Normal file
103
engine/core/Engine.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { SceneNode, Choice, EngineEvent } from '../types'
|
||||||
|
import { SceneManager } from './SceneManager'
|
||||||
|
import { VideoManager } from './VideoManager'
|
||||||
|
import { StateManager } from './StateManager'
|
||||||
|
|
||||||
|
type EventHandler = (...args: any[]) => void
|
||||||
|
|
||||||
|
export class Engine {
|
||||||
|
sceneManager: SceneManager
|
||||||
|
videoManager: VideoManager
|
||||||
|
stateManager: StateManager
|
||||||
|
|
||||||
|
private currentScene: SceneNode | null = null
|
||||||
|
private events: Map<EngineEvent, Set<EventHandler>> = new Map()
|
||||||
|
private ended: boolean = false
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.sceneManager = new SceneManager()
|
||||||
|
this.videoManager = new VideoManager()
|
||||||
|
this.stateManager = new StateManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: EngineEvent, handler: EventHandler) {
|
||||||
|
if (!this.events.has(event)) this.events.set(event, new Set())
|
||||||
|
this.events.get(event)!.add(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: EngineEvent, handler: EventHandler) {
|
||||||
|
this.events.get(event)?.delete(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: EngineEvent, ...args: any[]) {
|
||||||
|
this.events.get(event)?.forEach((h) => h(...args))
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.ended = false
|
||||||
|
const startScene = this.sceneManager.getStartScene()
|
||||||
|
this.goToScene(startScene)
|
||||||
|
}
|
||||||
|
|
||||||
|
private goToScene(scene: SceneNode) {
|
||||||
|
this.currentScene = scene
|
||||||
|
|
||||||
|
if (scene.onEnter) {
|
||||||
|
this.stateManager.apply(scene.onEnter)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.videoManager.play(scene.videoUrl)
|
||||||
|
this.emit('sceneChange', scene)
|
||||||
|
|
||||||
|
this.videoManager.onEnd(() => {
|
||||||
|
this.emit('videoEnd', scene)
|
||||||
|
this.onVideoEnd(scene)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private onVideoEnd(scene: SceneNode) {
|
||||||
|
if (scene.choices && scene.choices.length > 0) {
|
||||||
|
this.emit('choiceRequest', scene.choices)
|
||||||
|
} else if (scene.nextScene) {
|
||||||
|
const next = this.sceneManager.getScene(scene.nextScene)
|
||||||
|
if (next) {
|
||||||
|
this.goToScene(next)
|
||||||
|
} else {
|
||||||
|
this.endGame()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.endGame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
makeChoice(choice: Choice) {
|
||||||
|
if (!this.currentScene) return
|
||||||
|
|
||||||
|
if (choice.effects) {
|
||||||
|
this.stateManager.apply(choice.effects)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stateManager.recordChoice({
|
||||||
|
sceneId: this.currentScene.id,
|
||||||
|
choiceIndex: this.currentScene.choices?.indexOf(choice) ?? -1,
|
||||||
|
choiceText: choice.text,
|
||||||
|
})
|
||||||
|
|
||||||
|
const next = this.sceneManager.getScene(choice.targetScene)
|
||||||
|
if (next) {
|
||||||
|
this.goToScene(next)
|
||||||
|
} else {
|
||||||
|
this.endGame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endGame() {
|
||||||
|
this.ended = true
|
||||||
|
this.emit('gameEnd')
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.videoManager.detach()
|
||||||
|
this.events.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
25
engine/core/SceneManager.ts
Normal file
25
engine/core/SceneManager.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { GameData, SceneNode } from '../types'
|
||||||
|
|
||||||
|
export class SceneManager {
|
||||||
|
private scenes: Record<string, SceneNode> = {}
|
||||||
|
private startScene: string = ''
|
||||||
|
|
||||||
|
load(data: GameData) {
|
||||||
|
this.scenes = data.scenes
|
||||||
|
this.startScene = data.startScene
|
||||||
|
}
|
||||||
|
|
||||||
|
getScene(id: string): SceneNode | undefined {
|
||||||
|
return this.scenes[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
getStartScene(): SceneNode {
|
||||||
|
const scene = this.scenes[this.startScene]
|
||||||
|
if (!scene) throw new Error(`Start scene "${this.startScene}" not found`)
|
||||||
|
return scene
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllSceneIds(): string[] {
|
||||||
|
return Object.keys(this.scenes)
|
||||||
|
}
|
||||||
|
}
|
||||||
94
engine/core/StateManager.ts
Normal file
94
engine/core/StateManager.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { Condition, Effect, ChoiceRecord } from '../types'
|
||||||
|
|
||||||
|
export class StateManager {
|
||||||
|
variables: Record<string, number> = {}
|
||||||
|
flags: Set<string> = new Set()
|
||||||
|
history: ChoiceRecord[] = []
|
||||||
|
|
||||||
|
init(initialVars: Record<string, number>) {
|
||||||
|
this.variables = { ...initialVars }
|
||||||
|
this.flags = new Set()
|
||||||
|
this.history = []
|
||||||
|
}
|
||||||
|
|
||||||
|
getVar(name: string): number {
|
||||||
|
return this.variables[name] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
setVar(name: string, value: number) {
|
||||||
|
this.variables[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
addVar(name: string, delta: number) {
|
||||||
|
this.variables[name] = (this.variables[name] ?? 0) + delta
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFlag(name: string): boolean {
|
||||||
|
return this.flags.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFlag(name: string) {
|
||||||
|
this.flags.add(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFlag(name: string) {
|
||||||
|
this.flags.delete(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluate(conditions: Condition[]): boolean {
|
||||||
|
return conditions.every((c) => {
|
||||||
|
switch (c.op) {
|
||||||
|
case '==':
|
||||||
|
return this.variables[c.variable] === c.value
|
||||||
|
case '!=':
|
||||||
|
return this.variables[c.variable] !== c.value
|
||||||
|
case '>':
|
||||||
|
return (this.variables[c.variable] ?? 0) > (c.value as number)
|
||||||
|
case '<':
|
||||||
|
return (this.variables[c.variable] ?? 0) < (c.value as number)
|
||||||
|
case '>=':
|
||||||
|
return (this.variables[c.variable] ?? 0) >= (c.value as number)
|
||||||
|
case '<=':
|
||||||
|
return (this.variables[c.variable] ?? 0) <= (c.value as number)
|
||||||
|
case 'hasFlag':
|
||||||
|
return this.flags.has(c.variable)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(effects: Effect[]) {
|
||||||
|
for (const e of effects) {
|
||||||
|
switch (e.type) {
|
||||||
|
case 'set':
|
||||||
|
this.variables[e.target] = e.value as number
|
||||||
|
break
|
||||||
|
case 'add':
|
||||||
|
this.addVar(e.target, e.value as number)
|
||||||
|
break
|
||||||
|
case 'toggleFlag':
|
||||||
|
this.setFlag(e.target)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChoice(choice: ChoiceRecord) {
|
||||||
|
this.history.push(choice)
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
return {
|
||||||
|
variables: { ...this.variables },
|
||||||
|
flags: [...this.flags],
|
||||||
|
history: [...this.history],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fromJSON(data: { variables: Record<string, number>; flags: string[]; history: ChoiceRecord[] }) {
|
||||||
|
this.variables = { ...data.variables }
|
||||||
|
this.flags = new Set(data.flags)
|
||||||
|
this.history = [...data.history]
|
||||||
|
}
|
||||||
|
}
|
||||||
58
engine/core/VideoManager.ts
Normal file
58
engine/core/VideoManager.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
type VideoEndCallback = () => void
|
||||||
|
type TimeUpdateCallback = (time: number) => void
|
||||||
|
|
||||||
|
export class VideoManager {
|
||||||
|
private videoEl: HTMLVideoElement | null = null
|
||||||
|
private onEndCallback: VideoEndCallback | null = null
|
||||||
|
private onTimeCallback: TimeUpdateCallback | null = null
|
||||||
|
private lastSrc: string = ''
|
||||||
|
|
||||||
|
attach(videoEl: HTMLVideoElement) {
|
||||||
|
this.videoEl = videoEl
|
||||||
|
videoEl.addEventListener('ended', this.handleEnded)
|
||||||
|
videoEl.addEventListener('timeupdate', this.handleTimeUpdate)
|
||||||
|
}
|
||||||
|
|
||||||
|
detach() {
|
||||||
|
if (!this.videoEl) return
|
||||||
|
this.videoEl.removeEventListener('ended', this.handleEnded)
|
||||||
|
this.videoEl.removeEventListener('timeupdate', this.handleTimeUpdate)
|
||||||
|
this.videoEl = null
|
||||||
|
}
|
||||||
|
|
||||||
|
play(src: string) {
|
||||||
|
if (!this.videoEl) return
|
||||||
|
if (this.lastSrc !== src) {
|
||||||
|
this.videoEl.src = src
|
||||||
|
this.lastSrc = src
|
||||||
|
}
|
||||||
|
this.videoEl.currentTime = 0
|
||||||
|
this.videoEl.play().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
this.videoEl?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentTime(): number {
|
||||||
|
return this.videoEl?.currentTime ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
onEnd(cb: VideoEndCallback) {
|
||||||
|
this.onEndCallback = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
onTimeUpdate(cb: TimeUpdateCallback) {
|
||||||
|
this.onTimeCallback = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleEnded = () => {
|
||||||
|
this.onEndCallback?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleTimeUpdate = () => {
|
||||||
|
if (this.videoEl) {
|
||||||
|
this.onTimeCallback?.(this.videoEl.currentTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
engine/types.ts
Normal file
71
engine/types.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export interface SceneNode {
|
||||||
|
id: string
|
||||||
|
videoUrl: string
|
||||||
|
subtitleUrl?: string
|
||||||
|
choices?: Choice[]
|
||||||
|
qte?: QTEDefinition
|
||||||
|
nextScene?: string
|
||||||
|
onEnter?: Effect[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Choice {
|
||||||
|
text: string
|
||||||
|
targetScene: string
|
||||||
|
conditions?: Condition[]
|
||||||
|
effects?: Effect[]
|
||||||
|
timeLimit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Condition {
|
||||||
|
variable: string
|
||||||
|
op: '>' | '<' | '>=' | '<=' | '==' | '!=' | 'hasFlag'
|
||||||
|
value: number | string | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Effect {
|
||||||
|
type: 'set' | 'add' | 'toggleFlag' | 'triggerEvent'
|
||||||
|
target: string
|
||||||
|
value?: number | string | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QTEDefinition {
|
||||||
|
triggerTime: number
|
||||||
|
prompt: string
|
||||||
|
keys: string[]
|
||||||
|
timeLimit: number
|
||||||
|
successScene: string
|
||||||
|
failScene: string
|
||||||
|
effects?: {
|
||||||
|
success: Effect[]
|
||||||
|
fail: Effect[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameData {
|
||||||
|
scenes: Record<string, SceneNode>
|
||||||
|
startScene: string
|
||||||
|
variables: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChoiceRecord {
|
||||||
|
sceneId: string
|
||||||
|
choiceIndex: number
|
||||||
|
choiceText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveData {
|
||||||
|
slot: number
|
||||||
|
timestamp: number
|
||||||
|
currentScene: string
|
||||||
|
variables: Record<string, number>
|
||||||
|
flags: string[]
|
||||||
|
history: ChoiceRecord[]
|
||||||
|
thumbnail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EngineEvent =
|
||||||
|
| 'sceneChange'
|
||||||
|
| 'choiceRequest'
|
||||||
|
| 'gameEnd'
|
||||||
|
| 'qteTrigger'
|
||||||
|
| 'videoEnd'
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>交互式电影游戏</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1342
package-lock.json
generated
Normal file
1342
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "moviegame",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"pinia": "^2.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"typescript": "~5.6.0",
|
||||||
|
"vite": "^5.4.0",
|
||||||
|
"vue-tsc": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
public/scenes/demo.json
Normal file
84
public/scenes/demo.json
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"startScene": "intro",
|
||||||
|
"variables": {
|
||||||
|
"trust": 50,
|
||||||
|
"courage": 0
|
||||||
|
},
|
||||||
|
"scenes": {
|
||||||
|
"intro": {
|
||||||
|
"id": "intro",
|
||||||
|
"videoUrl": "/videos/intro.mp4",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"text": "走向左边那扇发光的门",
|
||||||
|
"targetScene": "left_door",
|
||||||
|
"effects": [
|
||||||
|
{ "type": "add", "target": "courage", "value": 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "走向右边那扇普通的门",
|
||||||
|
"targetScene": "right_door",
|
||||||
|
"effects": [
|
||||||
|
{ "type": "add", "target": "courage", "value": -5 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "留在原地,什么也不做",
|
||||||
|
"targetScene": "stay"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"left_door": {
|
||||||
|
"id": "left_door",
|
||||||
|
"videoUrl": "/videos/left_door.mp4",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"text": "与陌生人握手",
|
||||||
|
"targetScene": "trust_ending",
|
||||||
|
"effects": [
|
||||||
|
{ "type": "add", "target": "trust", "value": 30 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "拒绝握手,保持警惕",
|
||||||
|
"targetScene": "alone_ending"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"right_door": {
|
||||||
|
"id": "right_door",
|
||||||
|
"videoUrl": "/videos/right_door.mp4",
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"text": "继续前进",
|
||||||
|
"targetScene": "continue_ending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "回头",
|
||||||
|
"targetScene": "intro"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"stay": {
|
||||||
|
"id": "stay",
|
||||||
|
"videoUrl": "/videos/stay.mp4",
|
||||||
|
"nextScene": "alone_ending"
|
||||||
|
},
|
||||||
|
"trust_ending": {
|
||||||
|
"id": "trust_ending",
|
||||||
|
"videoUrl": "/videos/trust_ending.mp4",
|
||||||
|
"choices": []
|
||||||
|
},
|
||||||
|
"alone_ending": {
|
||||||
|
"id": "alone_ending",
|
||||||
|
"videoUrl": "/videos/alone_ending.mp4",
|
||||||
|
"choices": []
|
||||||
|
},
|
||||||
|
"continue_ending": {
|
||||||
|
"id": "continue_ending",
|
||||||
|
"videoUrl": "/videos/continue_ending.mp4",
|
||||||
|
"choices": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/videos/alone_ending.mp4
Normal file
BIN
public/videos/alone_ending.mp4
Normal file
Binary file not shown.
BIN
public/videos/continue_ending.mp4
Normal file
BIN
public/videos/continue_ending.mp4
Normal file
Binary file not shown.
BIN
public/videos/intro.mp4
Normal file
BIN
public/videos/intro.mp4
Normal file
Binary file not shown.
BIN
public/videos/left_door.mp4
Normal file
BIN
public/videos/left_door.mp4
Normal file
Binary file not shown.
BIN
public/videos/right_door.mp4
Normal file
BIN
public/videos/right_door.mp4
Normal file
Binary file not shown.
BIN
public/videos/stay.mp4
Normal file
BIN
public/videos/stay.mp4
Normal file
Binary file not shown.
BIN
public/videos/trust_ending.mp4
Normal file
BIN
public/videos/trust_ending.mp4
Normal file
Binary file not shown.
2080
session-ses_15fa.md
Normal file
2080
session-ses_15fa.md
Normal file
File diff suppressed because one or more lines are too long
103
src/App.vue
Normal file
103
src/App.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import GamePlayer from '@/components/GamePlayer.vue'
|
||||||
|
import ChoicePanel from '@/components/ChoicePanel.vue'
|
||||||
|
import { useGameEngine } from '@/composables/useGameEngine'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
|
||||||
|
const store = useGameStore()
|
||||||
|
const videoElRef = ref<HTMLVideoElement | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const { loadGame, start, makeChoice } = useGameEngine(() => videoElRef.value)
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await loadGame('/scenes/demo.json')
|
||||||
|
loading.value = false
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVideoReady(el: HTMLVideoElement) {
|
||||||
|
videoElRef.value = el
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChoose(index: number) {
|
||||||
|
makeChoice(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<div v-if="loading" class="loading">加载中...</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="game-screen">
|
||||||
|
<GamePlayer @video-ready="onVideoReady" />
|
||||||
|
<ChoicePanel :choices="store.choices" @choose="onChoose" />
|
||||||
|
</div>
|
||||||
|
<div v-if="store.gameEnded" class="game-end-overlay">
|
||||||
|
<div class="game-end-text">游戏结束</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-screen {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-end-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-end-text {
|
||||||
|
font-size: 36px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
71
src/components/ChoicePanel.vue
Normal file
71
src/components/ChoicePanel.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Choice } from '@engine/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
choices: Choice[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
choose: [index: number]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="choice-panel" v-if="choices.length > 0">
|
||||||
|
<div class="choice-prompt">做出你的选择</div>
|
||||||
|
<div class="choice-list">
|
||||||
|
<button
|
||||||
|
v-for="(choice, index) in choices"
|
||||||
|
:key="index"
|
||||||
|
class="choice-btn"
|
||||||
|
@click="emit('choose', index)"
|
||||||
|
>
|
||||||
|
{{ choice.text }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.choice-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
|
||||||
|
padding: 40px 20px 30px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-prompt {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn {
|
||||||
|
padding: 14px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
src/components/GamePlayer.vue
Normal file
40
src/components/GamePlayer.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
videoReady: [el: HTMLVideoElement]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (videoRef.value) {
|
||||||
|
emit('videoReady', videoRef.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ videoRef })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="game-player">
|
||||||
|
<video ref="videoRef" class="player-video" preload="auto"></video>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.game-player {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
53
src/composables/useGameEngine.ts
Normal file
53
src/composables/useGameEngine.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { Engine } from '@engine/core/Engine'
|
||||||
|
import type { GameData } from '@engine/types'
|
||||||
|
import { useGameStore } from '@/stores/gameStore'
|
||||||
|
|
||||||
|
export function useGameEngine(videoEl: () => HTMLVideoElement | null) {
|
||||||
|
const engine = new Engine()
|
||||||
|
const store = useGameStore()
|
||||||
|
|
||||||
|
async function loadGame(dataUrl: string) {
|
||||||
|
const resp = await fetch(dataUrl)
|
||||||
|
const data: GameData = await resp.json()
|
||||||
|
engine.sceneManager.load(data)
|
||||||
|
engine.stateManager.init(data.variables)
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
engine.videoManager.attach(videoEl()!)
|
||||||
|
|
||||||
|
engine.on('sceneChange', (scene) => {
|
||||||
|
store.setScene(scene)
|
||||||
|
store.clearChoices()
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.on('choiceRequest', (choiceList) => {
|
||||||
|
store.setChoices(choiceList)
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.on('videoEnd', () => {})
|
||||||
|
|
||||||
|
engine.on('gameEnd', () => {
|
||||||
|
store.setGameEnded(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
engine.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChoice(index: number) {
|
||||||
|
const scene = store.currentScene
|
||||||
|
if (!scene?.choices) return
|
||||||
|
engine.makeChoice(scene.choices[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
engine.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { loadGame, start, makeChoice, destroy, engine }
|
||||||
|
}
|
||||||
7
src/main.ts
Normal file
7
src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.mount('#app')
|
||||||
27
src/stores/gameStore.ts
Normal file
27
src/stores/gameStore.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, shallowRef } from 'vue'
|
||||||
|
import type { SceneNode, Choice } from '@engine/types'
|
||||||
|
|
||||||
|
export const useGameStore = defineStore('game', () => {
|
||||||
|
const currentScene = shallowRef<SceneNode | null>(null)
|
||||||
|
const choices = ref<Choice[]>([])
|
||||||
|
const gameEnded = ref(false)
|
||||||
|
|
||||||
|
function setScene(scene: SceneNode) {
|
||||||
|
currentScene.value = scene
|
||||||
|
}
|
||||||
|
|
||||||
|
function setChoices(list: Choice[]) {
|
||||||
|
choices.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChoices() {
|
||||||
|
choices.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGameEnded(val: boolean) {
|
||||||
|
gameEnded.value = val
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentScene, choices, gameEnded, setScene, setChoices, clearChoices, setGameEnded }
|
||||||
|
})
|
||||||
7
src/vite-env.d.ts
vendored
Normal file
7
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"@engine/*": ["./engine/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "engine/**/*.ts"]
|
||||||
|
}
|
||||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
'@engine': resolve(__dirname, 'engine'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user