feat: P2 - QTE system, subtitles, save thumbnails
- 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
This commit is contained in:
115
src/components/Subtitles.vue
Normal file
115
src/components/Subtitles.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user