feat: engine improvements, new scenes, videos, subtitles, hotspot component and docs update
This commit is contained in:
64
ROADMAP.md
64
ROADMAP.md
@@ -179,61 +179,75 @@ interface SaveData {
|
||||
- [x] `vite.config.ts` — 多页面构建(main + editor)
|
||||
- [x] 验证:编辑器能产出合法 JSON,引擎能正确加载并运行
|
||||
|
||||
### P4 图片热点 — 点击图片触发分支(待实现)
|
||||
### P4 视频/图片热点 — 点击画面区域触发分支 ✅ 已完成 2026-06-08
|
||||
|
||||
目标:场景支持静态图片替代视频,图上定义可点击热区,点击不同位置触发不同分支
|
||||
目标:在视频或图片上定义可点击热区(Hotspot),玩家点击画面不同位置触发不同分支。
|
||||
热区既可覆盖在静态图片上(调查/解谜场景),也可覆盖在播放中的视频上(根据时间轴淡入淡出)。
|
||||
|
||||
**视频热点 vs 图片热点(架构统一,差异仅两点):**
|
||||
|
||||
| | 图片热点 | 视频热点 |
|
||||
|------|----------|----------|
|
||||
| 底层内容 | `<img>` 元素 | `<video>` 元素(已经在播) |
|
||||
| 热点出现时机 | 始终可见 | 按时间轴出现/消失(`showAt`/`hideAt`) |
|
||||
|
||||
**场景数据设计:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "crime_scene",
|
||||
"type": "image",
|
||||
"imageUrl": "/images/crime_scene.jpg",
|
||||
"subtitleUrl": "/subtitles/crime_scene.vtt",
|
||||
"id": "investigation",
|
||||
"type": "video",
|
||||
"videoUrl": "/videos/investigation.mp4",
|
||||
"subtitleUrl": "/subtitles/investigation.vtt",
|
||||
"hotspots": [
|
||||
{
|
||||
"id": "hs_desk",
|
||||
"x": 0.15, "y": 0.25, "width": 0.18, "height": 0.12,
|
||||
"label": "查看书桌",
|
||||
"targetScene": "desk_detail",
|
||||
"x": 0.15, "y": 0.30, "width": 0.25, "height": 0.35,
|
||||
"showAt": 2.0,
|
||||
"hideAt": 8.0,
|
||||
"conditions": [{ "variable": "investigation", "op": ">=", "value": 1 }],
|
||||
"effects": [{ "type": "setFlag", "target": "checked_desk" }]
|
||||
},
|
||||
{
|
||||
"id": "hs_window",
|
||||
"x": 0.72, "y": 0.08, "width": 0.15, "height": 0.28,
|
||||
"label": "查看窗户",
|
||||
"targetScene": "window_detail"
|
||||
"label": "靠近窗户",
|
||||
"targetScene": "window_look",
|
||||
"x": 0.70, "y": 0.10, "width": 0.20, "height": 0.40,
|
||||
"showAt": 5.0,
|
||||
"hideAt": 10.0
|
||||
},
|
||||
{
|
||||
"id": "hs_body",
|
||||
"x": 0.40, "y": 0.40, "width": 0.10, "height": 0.22,
|
||||
"label": "检查尸体",
|
||||
"targetScene": "body_detail",
|
||||
"timeLimit": 10
|
||||
"id": "hs_door",
|
||||
"label": "离开房间",
|
||||
"targetScene": "leave_room",
|
||||
"x": 0.30, "y": 0.60, "width": 0.40, "height": 0.30,
|
||||
"timeLimit": 15
|
||||
}
|
||||
],
|
||||
"choices": [
|
||||
{ "text": "离开现场", "targetScene": "leave_room" }
|
||||
{ "text": "放弃调查", "targetScene": "give_up" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**字段约定:**
|
||||
- `type: "image"` — 声明为图片场景(默认/不存在则为视频场景)
|
||||
- `x/y/width/height` — 热区坐标,使用**相对比例**(0~1),自适应屏幕尺寸
|
||||
- 图片场景仍可同时附带底部 `choices`(如"离开"选项)
|
||||
- `hotspots` 支持 `conditions`(条件显隐)、`effects`(点击后效果)、`timeLimit`(限时)
|
||||
- `showAt`/`hideAt` — 视频热点的时间轴(秒),未设置时热区始终可见(兼容图片场景和始终可见的视频热点)
|
||||
- `hotspots` 支持 `conditions`(条件显隐)、`effects`(点击后效果)、`timeLimit`(限时热区)
|
||||
- 热点场景仍可同时附带底部 `choices`(如"放弃调查"按钮)
|
||||
- `type` 字段区分 `"video"`(默认)和 `"image"`(静态图,此时 `imageUrl` 替代 `videoUrl`)
|
||||
|
||||
**实现清单:**
|
||||
|
||||
- [ ] `engine/types.ts` — `SceneNode.type` 字段、`Hotspot` 接口
|
||||
- [ ] `engine/core/Engine.ts` — 支持 `type: "image"` 场景,挂载图片 + 热区
|
||||
- [ ] `src/components/ImageScene.vue` — 渲染图片 + 热区矩形 + hover 高亮 + label 浮动提示
|
||||
- [ ] `editor/components/NodeEditor.vue` — 场景类型切换(视频/图片)+ 热区可视化编辑(拖放矩形)
|
||||
- [ ] `public/scenes/demo.json` — 新增图片场景示例 + 示例图片
|
||||
- [ ] 验证:图片加载、热区点击触发分支、条件过滤、限时热区
|
||||
- [x] `engine/types.ts` — `SceneNode.type` 字段、`Hotspot` 接口(含 `showAt`/`hideAt`)
|
||||
- [x] `src/components/HotspotLayer.vue` — 通用热区覆盖层:叠加在视频或图片之上,render 热区矩形 + hover 高亮 + label 浮动提示
|
||||
- [x] `engine/core/Engine.ts` — 视频模式下监听 timeupdate,按时显隐热区;点击热区触发分支跳转
|
||||
- [ ] `editor/components/NodeEditor.vue` — 场景类型切换(视频/图片)+ 热区列表编辑 + 时间轴参数(showAt/hideAt)
|
||||
- [x] `public/images/` — 示例图片目录
|
||||
- [x] `public/scenes/demo.json` — 新增图片热点场景 `investigation_site` + 视频热点场景 `corridor`
|
||||
- [x] 验证:图片热区点击触发、视频热区按时出现/消失、条件过滤、hover 高亮
|
||||
|
||||
### P5 选择等待循环 — 视频结束循环播放(待实现)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SceneNode, Choice, EngineEvent } from '../types'
|
||||
import type { SceneNode, Choice, EngineEvent, Hotspot } from '../types'
|
||||
import { SceneManager } from './SceneManager'
|
||||
import { VideoManager } from './VideoManager'
|
||||
import { StateManager } from './StateManager'
|
||||
@@ -60,6 +60,16 @@ export class Engine {
|
||||
this.stateManager.apply(scene.onEnter)
|
||||
}
|
||||
|
||||
if (scene.type === 'image') {
|
||||
this.isInitialScene = false
|
||||
this.emit('sceneChange', scene)
|
||||
const visible = this.getVisibleHotspots(scene)
|
||||
if (visible.length > 0) {
|
||||
this.emit('hotspotRequest', visible)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const preloadUrls = this.sceneManager.getCandidateUrls(
|
||||
scene,
|
||||
(conds) => conds ? this.stateManager.evaluate(conds) : true
|
||||
@@ -84,7 +94,11 @@ export class Engine {
|
||||
|
||||
private checkQTE = (time: number) => {
|
||||
const scene = this.currentScene
|
||||
if (!scene?.qte || this.qteTriggered) return
|
||||
if (!scene) return
|
||||
|
||||
this.checkHotspotTime(scene, time)
|
||||
|
||||
if (!scene.qte || this.qteTriggered) return
|
||||
if (time >= scene.qte.triggerTime) {
|
||||
this.qteTriggered = true
|
||||
const qte = scene.qte
|
||||
@@ -126,6 +140,48 @@ export class Engine {
|
||||
}
|
||||
}
|
||||
|
||||
private checkHotspotTime(scene: SceneNode, time: number) {
|
||||
if (!scene.hotspots || scene.hotspots.length === 0) return
|
||||
|
||||
const visible = scene.hotspots.filter((hs) => {
|
||||
if (hs.conditions && !this.stateManager.evaluate(hs.conditions)) return false
|
||||
if (hs.showAt !== undefined && time < hs.showAt) return false
|
||||
if (hs.hideAt !== undefined && time >= hs.hideAt) return false
|
||||
return true
|
||||
})
|
||||
|
||||
this.emit('hotspotUpdate', visible)
|
||||
}
|
||||
|
||||
getVisibleHotspots(scene: SceneNode): Hotspot[] {
|
||||
if (!scene.hotspots) return []
|
||||
return scene.hotspots.filter((hs) => {
|
||||
if (hs.conditions && !this.stateManager.evaluate(hs.conditions)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
clickHotspot(hotspot: Hotspot) {
|
||||
if (!this.currentScene) return
|
||||
|
||||
if (hotspot.effects) {
|
||||
this.stateManager.apply(hotspot.effects)
|
||||
}
|
||||
|
||||
this.stateManager.recordChoice({
|
||||
sceneId: this.currentScene.id,
|
||||
choiceIndex: -1,
|
||||
choiceText: hotspot.label,
|
||||
})
|
||||
|
||||
const next = this.sceneManager.getScene(hotspot.targetScene)
|
||||
if (next) {
|
||||
this.goToScene(next)
|
||||
} else {
|
||||
this.endGame()
|
||||
}
|
||||
}
|
||||
|
||||
private onVideoEnd(scene: SceneNode) {
|
||||
const validChoices = this.getValidChoices(scene)
|
||||
|
||||
@@ -202,6 +258,15 @@ export class Engine {
|
||||
this.ended = false
|
||||
this.isInitialScene = false
|
||||
|
||||
if (scene.type === 'image') {
|
||||
this.emit('sceneChange', scene)
|
||||
const visible = this.getVisibleHotspots(scene)
|
||||
if (visible.length > 0) {
|
||||
this.emit('hotspotRequest', visible)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const preloadUrls = this.sceneManager.getCandidateUrls(
|
||||
scene,
|
||||
(conds) => conds ? this.stateManager.evaluate(conds) : true
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export interface SceneNode {
|
||||
id: string
|
||||
type?: 'video' | 'image'
|
||||
videoUrl: string
|
||||
imageUrl?: string
|
||||
subtitleUrl?: string
|
||||
choices?: Choice[]
|
||||
hotspots?: Hotspot[]
|
||||
qte?: QTEDefinition
|
||||
nextScene?: string
|
||||
onEnter?: Effect[]
|
||||
@@ -13,7 +16,22 @@ export interface Choice {
|
||||
targetScene: string
|
||||
conditions?: Condition[]
|
||||
effects?: Effect[]
|
||||
timeLimit?: number // 单位:秒,超时后自动选择第一项
|
||||
timeLimit?: number
|
||||
}
|
||||
|
||||
export interface Hotspot {
|
||||
id: string
|
||||
label: string
|
||||
targetScene: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
showAt?: number
|
||||
hideAt?: number
|
||||
conditions?: Condition[]
|
||||
effects?: Effect[]
|
||||
timeLimit?: number
|
||||
}
|
||||
|
||||
export interface Condition {
|
||||
@@ -73,3 +91,5 @@ export type EngineEvent =
|
||||
| 'qteResult'
|
||||
| 'videoEnd'
|
||||
| 'choiceTimeout'
|
||||
| 'hotspotRequest'
|
||||
| 'hotspotUpdate'
|
||||
|
||||
BIN
public/images/investigation_scene.jpg
Normal file
BIN
public/images/investigation_scene.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -2,7 +2,8 @@
|
||||
"startScene": "intro",
|
||||
"variables": {
|
||||
"trust": 50,
|
||||
"courage": 0
|
||||
"courage": 0,
|
||||
"investigation": 0
|
||||
},
|
||||
"scenes": {
|
||||
"intro": {
|
||||
@@ -24,12 +25,86 @@
|
||||
{ "type": "add", "target": "courage", "value": -5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "仔细搜索房间",
|
||||
"targetScene": "investigation_site"
|
||||
},
|
||||
{
|
||||
"text": "留在原地,什么也不做",
|
||||
"targetScene": "stay"
|
||||
}
|
||||
]
|
||||
},
|
||||
"investigation_site": {
|
||||
"id": "investigation_site",
|
||||
"type": "image",
|
||||
"videoUrl": "",
|
||||
"imageUrl": "/images/investigation_scene.jpg",
|
||||
"subtitleUrl": "/subtitles/investigation.vtt",
|
||||
"hotspots": [
|
||||
{
|
||||
"id": "hs_desk",
|
||||
"label": "查看书桌",
|
||||
"targetScene": "desk_detail",
|
||||
"x": 0.12, "y": 0.20, "width": 0.18, "height": 0.14,
|
||||
"effects": [
|
||||
{ "type": "add", "target": "investigation", "value": 1 },
|
||||
{ "type": "toggleFlag", "target": "checked_desk" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hs_window",
|
||||
"label": "查看窗户",
|
||||
"targetScene": "corridor",
|
||||
"x": 0.47, "y": 0.06, "width": 0.15, "height": 0.28
|
||||
},
|
||||
{
|
||||
"id": "hs_closet",
|
||||
"label": "检查衣柜",
|
||||
"targetScene": "desk_detail",
|
||||
"x": 0.33, "y": 0.48, "width": 0.10, "height": 0.26,
|
||||
"conditions": [
|
||||
{ "variable": "investigation", "op": ">=", "value": 1 }
|
||||
],
|
||||
"effects": [
|
||||
{ "type": "add", "target": "investigation", "value": 1 }
|
||||
]
|
||||
}
|
||||
],
|
||||
"choices": [
|
||||
{ "text": "离开房间", "targetScene": "corridor" }
|
||||
]
|
||||
},
|
||||
"corridor": {
|
||||
"id": "corridor",
|
||||
"videoUrl": "/videos/corridor.mp4",
|
||||
"hotspots": [
|
||||
{
|
||||
"id": "hs_left",
|
||||
"label": "走向左边通道",
|
||||
"targetScene": "left_door",
|
||||
"x": 0.02, "y": 0.30, "width": 0.30, "height": 0.45,
|
||||
"showAt": 1.5,
|
||||
"effects": [
|
||||
{ "type": "add", "target": "courage", "value": 5 }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "hs_center",
|
||||
"label": "走向中间通道",
|
||||
"targetScene": "trust_ending",
|
||||
"x": 0.33, "y": 0.25, "width": 0.34, "height": 0.55,
|
||||
"showAt": 3.0
|
||||
},
|
||||
{
|
||||
"id": "hs_right",
|
||||
"label": "走向右边通道",
|
||||
"targetScene": "alone_ending",
|
||||
"x": 0.68, "y": 0.30, "width": 0.30, "height": 0.45,
|
||||
"showAt": 5.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"left_door": {
|
||||
"id": "left_door",
|
||||
"videoUrl": "/videos/left_door.mp4",
|
||||
@@ -92,6 +167,20 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"desk_detail": {
|
||||
"id": "desk_detail",
|
||||
"videoUrl": "/videos/continue_ending.mp4",
|
||||
"choices": [
|
||||
{
|
||||
"text": "返回调查现场",
|
||||
"targetScene": "investigation_site"
|
||||
},
|
||||
{
|
||||
"text": "离开",
|
||||
"targetScene": "corridor"
|
||||
}
|
||||
]
|
||||
},
|
||||
"stay": {
|
||||
"id": "stay",
|
||||
"videoUrl": "/videos/stay.mp4",
|
||||
|
||||
4
public/subtitles/investigation.vtt
Normal file
4
public/subtitles/investigation.vtt
Normal file
@@ -0,0 +1,4 @@
|
||||
WEBVTT
|
||||
|
||||
00:00.000 --> 00:05.000
|
||||
你走进了一个凌乱的房间,仔细观察四周...
|
||||
Binary file not shown.
Binary file not shown.
BIN
public/videos/corridor.mp4
Normal file
BIN
public/videos/corridor.mp4
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
11
src/App.vue
11
src/App.vue
@@ -4,6 +4,7 @@ import GamePlayer from '@/components/GamePlayer.vue'
|
||||
import ChoicePanel from '@/components/ChoicePanel.vue'
|
||||
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 { useGameEngine } from '@/composables/useGameEngine'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
@@ -16,7 +17,7 @@ const started = ref(false)
|
||||
const showMenu = ref(false)
|
||||
const hasAutoSave = ref(false)
|
||||
|
||||
const { loadGame, start, resumeAutoSave, makeChoice, saveGame, loadGameFromSlot, refreshSaves, saveSystem } =
|
||||
const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, saveGame, loadGameFromSlot, refreshSaves, saveSystem } =
|
||||
useGameEngine(() => [videoElA.value, videoElB.value])
|
||||
|
||||
async function init() {
|
||||
@@ -68,7 +69,13 @@ init()
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
<template v-else>
|
||||
<div class="game-screen">
|
||||
<GamePlayer @video-ready="onVideoReady" />
|
||||
<GamePlayer v-if="!store.isImageScene" @video-ready="onVideoReady" />
|
||||
<HotspotLayer
|
||||
:hotspots="store.hotspots"
|
||||
:is-image-scene="store.isImageScene"
|
||||
:image-url="store.currentScene?.imageUrl"
|
||||
@click-hotspot="clickHotspot"
|
||||
/>
|
||||
<Subtitles
|
||||
:current-time="store.videoTime"
|
||||
:subtitle-url="store.currentScene?.subtitleUrl ?? null"
|
||||
|
||||
85
src/components/HotspotLayer.vue
Normal file
85
src/components/HotspotLayer.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { Hotspot } from '@engine/types'
|
||||
|
||||
const props = defineProps<{
|
||||
hotspots: Hotspot[]
|
||||
isImageScene: boolean
|
||||
imageUrl?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
clickHotspot: [hotspotId: string]
|
||||
}>()
|
||||
|
||||
function hsStyle(hs: Hotspot) {
|
||||
return {
|
||||
left: `${hs.x * 100}%`,
|
||||
top: `${hs.y * 100}%`,
|
||||
width: `${hs.width * 100}%`,
|
||||
height: `${hs.height * 100}%`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hotspot-layer" v-if="(hotspots.length > 0) || (isImageScene && imageUrl)">
|
||||
<img v-if="isImageScene && imageUrl" :src="imageUrl" class="hotspot-image" />
|
||||
|
||||
<div
|
||||
v-for="hs in hotspots"
|
||||
:key="hs.id"
|
||||
class="hotspot-rect"
|
||||
:style="hsStyle(hs)"
|
||||
@click.stop="emit('clickHotspot', hs.id)"
|
||||
:title="hs.label"
|
||||
>
|
||||
<span class="hotspot-label">{{ hs.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hotspot-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hotspot-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hotspot-rect {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hotspot-rect:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.hotspot-label {
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,8 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
||||
store.setScene(scene)
|
||||
store.clearChoices()
|
||||
store.clearTimer()
|
||||
store.clearHotspots()
|
||||
store.setIsImageScene(scene.type === 'image')
|
||||
saveGame(0)
|
||||
})
|
||||
|
||||
@@ -43,6 +45,14 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
||||
store.clearTimer()
|
||||
})
|
||||
|
||||
engine.on('hotspotRequest', (list) => {
|
||||
store.setHotspots(list)
|
||||
})
|
||||
|
||||
engine.on('hotspotUpdate', (list) => {
|
||||
store.setHotspots(list)
|
||||
})
|
||||
|
||||
engine.on('videoEnd', () => {
|
||||
try {
|
||||
const video = engine.videoManager.getActiveVideoElement()
|
||||
@@ -83,14 +93,14 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
||||
|
||||
function start() {
|
||||
const [elA, elB] = videoEls()
|
||||
engine.videoManager.attach(elA!, elB!)
|
||||
if (elA && elB) engine.videoManager.attach(elA, elB)
|
||||
registerEvents()
|
||||
engine.start()
|
||||
}
|
||||
|
||||
async function resumeAutoSave(): Promise<boolean> {
|
||||
const [elA, elB] = videoEls()
|
||||
engine.videoManager.attach(elA!, elB!)
|
||||
if (elA && elB) engine.videoManager.attach(elA, elB)
|
||||
registerEvents()
|
||||
return await loadGameFromSlot(0)
|
||||
}
|
||||
@@ -103,6 +113,15 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
||||
engine.makeChoice(scene.choices[index])
|
||||
}
|
||||
|
||||
function clickHotspot(hotspotId: string) {
|
||||
const scene = store.currentScene
|
||||
if (!scene?.hotspots) return
|
||||
const hs = scene.hotspots.find((h) => h.id === hotspotId)
|
||||
if (hs) {
|
||||
engine.clickHotspot(hs)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGame(slot: number) {
|
||||
const state = engine.stateManager
|
||||
const currentScene = store.currentScene
|
||||
@@ -144,5 +163,5 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide
|
||||
destroy()
|
||||
})
|
||||
|
||||
return { loadGame, start, resumeAutoSave, makeChoice, saveGame, loadGameFromSlot, refreshSaves, engine, saveSystem }
|
||||
return { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, saveGame, loadGameFromSlot, refreshSaves, engine, saveSystem }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, shallowRef } from 'vue'
|
||||
import type { SceneNode, Choice, QTEDefinition } from '@engine/types'
|
||||
import type { SceneNode, Choice, QTEDefinition, Hotspot } from '@engine/types'
|
||||
|
||||
export interface SlotInfo {
|
||||
slot: number
|
||||
@@ -23,6 +23,8 @@ export const useGameStore = defineStore('game', () => {
|
||||
const qteRemaining = ref(0)
|
||||
const qteResult = ref<'none' | 'success' | 'fail'>('none')
|
||||
const videoTime = ref(0)
|
||||
const hotspots = ref<Hotspot[]>([])
|
||||
const isImageScene = ref(false)
|
||||
|
||||
function setScene(scene: SceneNode) {
|
||||
currentScene.value = scene
|
||||
@@ -79,6 +81,18 @@ export const useGameStore = defineStore('game', () => {
|
||||
videoTime.value = t
|
||||
}
|
||||
|
||||
function setHotspots(list: Hotspot[]) {
|
||||
hotspots.value = list
|
||||
}
|
||||
|
||||
function clearHotspots() {
|
||||
hotspots.value = []
|
||||
}
|
||||
|
||||
function setIsImageScene(val: boolean) {
|
||||
isImageScene.value = val
|
||||
}
|
||||
|
||||
function dump() {
|
||||
console.group('GameStore')
|
||||
console.log('currentScene:', currentScene.value?.id)
|
||||
@@ -94,9 +108,11 @@ export const useGameStore = defineStore('game', () => {
|
||||
return {
|
||||
currentScene, choices, gameEnded, timerTotal, timerRemaining, saves,
|
||||
qteActive, qteDef, qteTotal, qteRemaining, qteResult, videoTime,
|
||||
hotspots, isImageScene,
|
||||
setScene, setChoices, clearChoices, setGameEnded,
|
||||
setTimer, clearTimer, setSaves,
|
||||
showQTE, updateQTE, resolveQTE, setVideoTime,
|
||||
setHotspots, clearHotspots, setIsImageScene,
|
||||
dump,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user