This commit is contained in:
2026-06-07 13:50:05 +08:00
commit aeb6dc46a4
28 changed files with 4458 additions and 0 deletions

24
.gitignore vendored Normal file
View 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
View 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
View 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()
}
}

View 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)
}
}

View 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]
}
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View 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
View 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": []
}
}
}

Binary file not shown.

Binary file not shown.

BIN
public/videos/intro.mp4 Normal file

Binary file not shown.

BIN
public/videos/left_door.mp4 Normal file

Binary file not shown.

Binary file not shown.

BIN
public/videos/stay.mp4 Normal file

Binary file not shown.

Binary file not shown.

2080
session-ses_15fa.md Normal file

File diff suppressed because one or more lines are too long

103
src/App.vue Normal file
View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View 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
View 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'),
},
},
})