feat: engine improvements, new scenes, videos, subtitles, hotspot component and docs update

This commit is contained in:
2026-06-08 14:01:58 +08:00
parent e68ed9c962
commit 6b67989007
20 changed files with 354 additions and 35 deletions

View File

@@ -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 选择等待循环 — 视频结束循环播放(待实现)

View File

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

View File

@@ -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'

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -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",

View 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

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.

View File

@@ -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"

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

View File

@@ -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 }
}

View File

@@ -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,
}
})