feat: StoryPlayer 台词流演绎器(气泡+朗读+逐字高亮+表情切换+跳过)

This commit is contained in:
2026-08-13 16:06:07 +08:00
parent 7940b3d4c6
commit 58b3e5752c
+123
View File
@@ -0,0 +1,123 @@
<template>
<view class="story">
<view class="story-skip" @click="finish">跳过 </view>
<view v-if="character" class="story-char">
<image v-if="character.image" class="story-char-img" :src="character.image" mode="aspectFit" />
<view v-else class="story-char-avatar" :style="{ background: charColor }">{{ character.name.slice(0, 1) }}</view>
<view class="story-mood" :class="{ pop: moodTick }">{{ moodEmoji }}</view>
</view>
<view class="story-lines">
<view v-for="(line, i) in script" :key="i" class="story-line" :class="{ active: i === lineIndex, done: i < lineIndex }">
<view v-if="line.speaker === '旁白'" class="bubble nar-bubble">
<view class="bubble-name">📖 旁白</view>
<RubyText :text="line.text" :pinyin="line.pinyin || ''" :show="showPinyin" :highlight="i === lineIndex ? charIndex : -1" />
</view>
<view v-else class="bubble char-bubble">
<view class="bubble-name">{{ line.speaker }}</view>
<RubyText :text="line.text" :pinyin="line.pinyin || ''" :show="showPinyin" :highlight="i === lineIndex ? charIndex : -1" />
</view>
</view>
</view>
</view>
</template>
<script>
import RubyText from './RubyText.vue'
import { personVisual } from '../utils/visual.js'
import { speak, stopSpeak } from '../utils/speech.js'
const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' }
const PER_CHAR = 260 // 每字高亮推进毫秒(与 TTS 朗读大致同步)
const PAUSE_MS = 700 // 句间停顿
export default {
name: 'StoryPlayer',
components: { RubyText },
props: {
script: { type: Array, default: () => [] }, // [{speaker, text, pinyin, emotion}]
character: { type: Object, default: null }, // {name, image, id}
showPinyin: { type: Boolean, default: true }
},
emits: ['done'],
data() {
return { lineIndex: 0, charIndex: 0, speaking: false, moodTick: false, timers: [] }
},
computed: {
moodEmoji() {
const line = this.script[this.lineIndex]
return EMOJI[(line && line.emotion) || 'normal'] || EMOJI.normal
},
charColor() {
return personVisual(this.character && this.character.id ? this.character.id : 0).color
}
},
mounted() {
this.playLine(0)
},
beforeUnmount() {
this.clearTimers()
stopSpeak()
},
methods: {
clearTimers() {
this.timers.forEach(t => { clearTimeout(t); clearInterval(t) })
this.timers = []
},
after(ms, fn) {
this.timers.push(setTimeout(fn, ms))
},
playLine(i) {
const line = this.script[i]
if (!line) {
this.$emit('done')
return
}
this.lineIndex = i
this.charIndex = 0
this.moodTick = true
this.after(300, () => { this.moodTick = false })
this.speaking = true
speak(line.text, '')
uni.pageScrollTo({ scrollTop: 99999, duration: 200 })
const total = Array.from(line.text).length
const step = setInterval(() => {
this.charIndex++
if (this.charIndex > total) {
clearInterval(step)
this.speaking = false
this.after(PAUSE_MS, () => this.playLine(i + 1))
}
}, PER_CHAR)
this.timers.push(step)
},
finish() {
this.clearTimers()
stopSpeak()
this.$emit('done')
}
}
}
</script>
<style scoped>
.story { position: relative; padding: 24rpx 8rpx 32rpx; }
.story-skip { position: absolute; top: 0; right: 0; font-size: 26rpx; color: #a08c74; background: #fff; border-radius: 30rpx; padding: 8rpx 24rpx; z-index: 2; }
.story-char { position: fixed; right: 24rpx; bottom: 48rpx; z-index: 3; }
.story-char-img { width: 140rpx; height: 140rpx; border-radius: 50%; background: #f8f2e8; box-shadow: 0 8rpx 24rpx rgba(91, 70, 54, 0.15); }
.story-char-avatar { width: 140rpx; height: 140rpx; border-radius: 50%; color: #fff; font-size: 64rpx; font-weight: 800; display: flex; align-items: center; justify-content: center; }
.story-mood { position: absolute; right: -10rpx; bottom: -10rpx; font-size: 44rpx; background: #fff; border-radius: 50%; width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; box-shadow: 0 2rpx 8rpx rgba(91, 70, 54, 0.2); }
.story-mood.pop { animation: mood-pop 0.35s ease; }
@keyframes mood-pop { 0% { transform: scale(0.3) rotate(-20deg); } 100% { transform: scale(1) rotate(0); } }
.story-lines { display: flex; flex-direction: column; gap: 24rpx; padding-bottom: 24rpx; }
.story-line { opacity: 0; transition: opacity 0.4s; }
.story-line.active { opacity: 1; }
.story-line.done { opacity: 0.55; }
.bubble { border-radius: 24rpx; padding: 24rpx 28rpx; box-shadow: 0 4rpx 16rpx rgba(91, 70, 54, 0.08); }
.bubble-name { font-size: 24rpx; font-weight: 700; color: #ff6b35; margin-bottom: 8rpx; }
.nar-bubble { background: #fff3d6; margin-left: 40rpx; margin-right: 40rpx; }
.char-bubble { background: #ffffff; margin-left: 32rpx; margin-right: 8rpx; }
.story-line.active .bubble { animation: bubble-in 0.35s ease; }
@keyframes bubble-in { 0% { opacity: 0; transform: translateY(24rpx) scale(0.96); } 100% { opacity: 1; transform: translateY(0) scale(1); } }
</style>