feat: switch hotspot coordinates from container percentage to absolute content pixels

This commit is contained in:
2026-06-10 12:45:41 +08:00
parent 5eac0f23a8
commit bb289f5438
4 changed files with 75 additions and 15 deletions

View File

@@ -3,6 +3,7 @@ export interface SceneNode {
type?: 'video' | 'image' type?: 'video' | 'image'
videoUrl: string videoUrl: string
imageUrl?: string imageUrl?: string
contentSize?: { w: number; h: number }
subtitleUrl?: string subtitleUrl?: string
subtitles?: Record<string, string> subtitles?: Record<string, string>
choices?: Choice[] choices?: Choice[]

View File

@@ -112,6 +112,7 @@
"type": "image", "type": "image",
"videoUrl": "", "videoUrl": "",
"imageUrl": "investigation_site/investigation_scene.jpg", "imageUrl": "investigation_site/investigation_scene.jpg",
"contentSize": { "w": 1280, "h": 720 },
"subtitleUrl": "investigation_site/investigation.vtt", "subtitleUrl": "investigation_site/investigation.vtt",
"subtitles": { "subtitles": {
"zh": "investigation_site/investigation.vtt", "zh": "investigation_site/investigation.vtt",
@@ -123,7 +124,7 @@
"id": "hs_desk", "id": "hs_desk",
"label": "查看书桌", "label": "查看书桌",
"targetScene": "desk_detail", "targetScene": "desk_detail",
"x": 0.12, "y": 0.20, "width": 0.18, "height": 0.14, "x": 154, "y": 144, "width": 230, "height": 101,
"effects": [ "effects": [
{ "type": "add", "target": "investigation", "value": 1 }, { "type": "add", "target": "investigation", "value": 1 },
{ "type": "toggleFlag", "target": "checked_desk" } { "type": "toggleFlag", "target": "checked_desk" }
@@ -133,13 +134,13 @@
"id": "hs_window", "id": "hs_window",
"label": "查看窗户", "label": "查看窗户",
"targetScene": "corridor", "targetScene": "corridor",
"x": 0.47, "y": 0.06, "width": 0.15, "height": 0.28 "x": 602, "y": 43, "width": 192, "height": 202
}, },
{ {
"id": "hs_closet", "id": "hs_closet",
"label": "检查衣柜", "label": "检查衣柜",
"targetScene": "desk_detail", "targetScene": "desk_detail",
"x": 0.33, "y": 0.48, "width": 0.10, "height": 0.26, "x": 422, "y": 346, "width": 128, "height": 187,
"conditions": [ "conditions": [
{ "variable": "investigation", "op": ">=", "value": 1 } { "variable": "investigation", "op": ">=", "value": 1 }
], ],
@@ -154,12 +155,13 @@
"corridor": { "corridor": {
"id": "corridor", "id": "corridor",
"videoUrl": "corridor/corridor.mp4", "videoUrl": "corridor/corridor.mp4",
"contentSize": { "w": 1280, "h": 720 },
"hotspots": [ "hotspots": [
{ {
"id": "hs_left", "id": "hs_left",
"label": "走向左边通道", "label": "走向左边通道",
"targetScene": "left_door", "targetScene": "left_door",
"x": 0.02, "y": 0.30, "width": 0.30, "height": 0.45, "x": 26, "y": 216, "width": 384, "height": 324,
"showAt": 1.5, "showAt": 1.5,
"effects": [ "effects": [
{ "type": "add", "target": "courage", "value": 5 } { "type": "add", "target": "courage", "value": 5 }
@@ -169,14 +171,14 @@
"id": "hs_center", "id": "hs_center",
"label": "走向中间通道", "label": "走向中间通道",
"targetScene": "trust_ending", "targetScene": "trust_ending",
"x": 0.33, "y": 0.25, "width": 0.34, "height": 0.55, "x": 422, "y": 180, "width": 435, "height": 396,
"showAt": 3.0 "showAt": 3.0
}, },
{ {
"id": "hs_right", "id": "hs_right",
"label": "走向右边通道", "label": "走向右边通道",
"targetScene": "alone_ending", "targetScene": "alone_ending",
"x": 0.68, "y": 0.30, "width": 0.30, "height": 0.45, "x": 870, "y": 216, "width": 384, "height": 324,
"showAt": 5.0 "showAt": 5.0
} }
] ]

View File

@@ -218,6 +218,7 @@ init()
:hotspots="store.hotspots" :hotspots="store.hotspots"
:is-image-scene="store.isImageScene" :is-image-scene="store.isImageScene"
:image-url="store.currentScene?.imageUrl" :image-url="store.currentScene?.imageUrl"
:content-size="store.currentScene?.contentSize ?? null"
@click-hotspot="clickHotspot" @click-hotspot="clickHotspot"
/> />
<Subtitles <Subtitles

View File

@@ -1,35 +1,91 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { Hotspot } from '@engine/types' import type { Hotspot } from '@engine/types'
const props = defineProps<{ const props = defineProps<{
hotspots: Hotspot[] hotspots: Hotspot[]
isImageScene: boolean isImageScene: boolean
imageUrl?: string | null imageUrl?: string | null
contentSize?: { w: number; h: number } | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
clickHotspot: [hotspotId: string] clickHotspot: [hotspotId: string]
}>() }>()
function hsStyle(hs: Hotspot) { const layerRef = ref<HTMLDivElement | null>(null)
return { const layerW = ref(window.innerWidth)
left: `${hs.x * 100}%`, const layerH = ref(window.innerHeight)
top: `${hs.y * 100}%`,
width: `${hs.width * 100}%`, function measure() {
height: `${hs.height * 100}%`, if (layerRef.value) {
layerW.value = layerRef.value.clientWidth
layerH.value = layerRef.value.clientHeight
} else {
layerW.value = window.innerWidth
layerH.value = window.innerHeight
} }
} }
let resizeObs: ResizeObserver | null = null
onMounted(() => {
measure()
resizeObs = new ResizeObserver(() => measure())
if (layerRef.value) resizeObs.observe(layerRef.value)
window.addEventListener('resize', measure)
})
onUnmounted(() => {
resizeObs?.disconnect()
window.removeEventListener('resize', measure)
})
interface Rect {
left: string
top: string
width: string
height: string
}
const hotspotRects = computed<Rect[]>(() => {
const cw = layerW.value
const ch = layerH.value
const cs = props.contentSize
if (!cs || !cs.w || !cs.h) {
return props.hotspots.map((hs) => ({
left: `${(hs.x / (cs?.w || 1)) * 100}%`,
top: `${(hs.y / (cs?.h || 1)) * 100}%`,
width: `${(hs.width / (cs?.w || 1)) * 100}%`,
height: `${(hs.height / (cs?.h || 1)) * 100}%`,
}))
}
const scale = Math.min(cw / cs.w, ch / cs.h)
const renderW = cs.w * scale
const renderH = cs.h * scale
const ox = (cw - renderW) / 2
const oy = (ch - renderH) / 2
return props.hotspots.map((hs) => ({
left: `${ox + hs.x * scale}px`,
top: `${oy + hs.y * scale}px`,
width: `${hs.width * scale}px`,
height: `${hs.height * scale}px`,
}))
})
</script> </script>
<template> <template>
<div class="hotspot-layer" v-if="(hotspots.length > 0) || (isImageScene && imageUrl)"> <div ref="layerRef" class="hotspot-layer" v-if="(hotspots.length > 0) || (isImageScene && imageUrl)">
<img v-if="isImageScene && imageUrl" :src="imageUrl" class="hotspot-image" /> <img v-if="isImageScene && imageUrl" :src="imageUrl" class="hotspot-image" />
<div <div
v-for="hs in hotspots" v-for="(hs, i) in hotspots"
:key="hs.id" :key="hs.id"
class="hotspot-rect" class="hotspot-rect"
:style="hsStyle(hs)" :style="hotspotRects[i] as any"
@click.stop="emit('clickHotspot', hs.id)" @click.stop="emit('clickHotspot', hs.id)"
:title="hs.label" :title="hs.label"
> >