- QTESystem: trigger detection via timeupdate, multi-key matching, timeout handling - QTEOverlay: SVG countdown ring + key prompts + success/fail animation - Engine: integrate QTE (timeupdate check, conditional branching, effect application) - Subtitles: WebVTT parsing + synchronized subtitle rendering - GamePlayer: overlay QTE and subtitle components - SaveSystem: DB v2 with thumbnail field, canvas snapshot at 320x180 JPEG - SaveLoadMenu: thumbnail preview for save slots - VideoManager: getActiveVideoElement() for canvas capture - App.vue: QTE/subtitle integration, thumbnail capture on save - stores: QTE state management, save list with thumbnails - demo.json: QTE scene (right_door), subtitles, new event types - ROADMAP: mark P2 as completed
116 lines
2.5 KiB
Vue
116 lines
2.5 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch } from 'vue'
|
|
|
|
interface SubCue {
|
|
start: number
|
|
end: number
|
|
text: string
|
|
}
|
|
|
|
const props = defineProps<{
|
|
currentTime: number
|
|
videoUrl: string | null
|
|
}>()
|
|
|
|
const cues = ref<SubCue[]>([])
|
|
const currentText = ref('')
|
|
const loadedUrl = ref('')
|
|
|
|
watch(() => props.videoUrl, async (url) => {
|
|
if (!url || url === loadedUrl.value) return
|
|
loadedUrl.value = url
|
|
try {
|
|
const resp = await fetch(url)
|
|
const text = await resp.text()
|
|
cues.value = parseVTT(text)
|
|
} catch {
|
|
cues.value = []
|
|
}
|
|
}, { immediate: true })
|
|
|
|
watch(() => props.currentTime, (t) => {
|
|
if (cues.value.length === 0) {
|
|
currentText.value = ''
|
|
return
|
|
}
|
|
const active = cues.value.find((c) => t >= c.start && t <= c.end)
|
|
currentText.value = active?.text ?? ''
|
|
})
|
|
|
|
function parseVTT(raw: string): SubCue[] {
|
|
const result: SubCue[] = []
|
|
const lines = raw.split(/\r?\n/)
|
|
let i = 0
|
|
|
|
while (i < lines.length && !lines[i].includes('-->')) {
|
|
i++
|
|
}
|
|
|
|
for (; i < lines.length; i++) {
|
|
const line = lines[i].trim()
|
|
const timeMatch = line.match(
|
|
/(\d{1,2}:)?(\d{1,2}:)?(\d{1,2}[.,]\d{1,3})\s*-->\s*(\d{1,2}:)?(\d{1,2}:)?(\d{1,2}[.,]\d{1,3})/
|
|
)
|
|
if (!timeMatch) continue
|
|
|
|
const start = vttTimeToSeconds(timeMatch[0].split('-->')[0].trim())
|
|
const end = vttTimeToSeconds(timeMatch[0].split('-->')[1].trim())
|
|
|
|
let text = ''
|
|
i++
|
|
while (i < lines.length && lines[i].trim() !== '') {
|
|
text += (text ? '\n' : '') + lines[i].trim()
|
|
i++
|
|
}
|
|
|
|
if (text) {
|
|
result.push({ start, end, text })
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function vttTimeToSeconds(ts: string): number {
|
|
const parts = ts.replace(',', '.').split(':')
|
|
if (parts.length === 3) {
|
|
return parseFloat(parts[0]) * 3600 + parseFloat(parts[1]) * 60 + parseFloat(parts[2])
|
|
}
|
|
if (parts.length === 2) {
|
|
return parseFloat(parts[0]) * 60 + parseFloat(parts[1])
|
|
}
|
|
return parseFloat(parts[0])
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="subtitles" v-if="currentText">
|
|
<div class="sub-text">{{ currentText }}</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.subtitles {
|
|
position: absolute;
|
|
bottom: 80px;
|
|
left: 0;
|
|
right: 0;
|
|
display: flex;
|
|
justify-content: center;
|
|
z-index: 5;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.sub-text {
|
|
background: rgba(0, 0, 0, 0.7);
|
|
color: #fff;
|
|
font-size: 20px;
|
|
padding: 8px 20px;
|
|
border-radius: 4px;
|
|
max-width: 80%;
|
|
text-align: center;
|
|
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
|
white-space: pre-line;
|
|
}
|
|
</style>
|