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

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