148 lines
6.4 KiB
Vue
148 lines
6.4 KiB
Vue
<template>
|
||
<view class="theater" :style="{ background: sceneColor + 'ee' }">
|
||
<image v-if="sceneImage" class="theater-bg" :src="sceneImage" mode="aspectFill" />
|
||
<view class="theater-skip" @click="finish">跳过 ›</view>
|
||
|
||
<view class="theater-scene">
|
||
<view class="theater-emoji">{{ sceneEmoji }}</view>
|
||
<view class="theater-name">{{ sceneName }}</view>
|
||
</view>
|
||
|
||
<!-- 道具飞入(右侧逐个) -->
|
||
<view class="theater-props">
|
||
<view
|
||
v-for="(p, i) in props"
|
||
:key="i"
|
||
class="theater-prop"
|
||
:style="{ animationDelay: 0.6 + i * 0.3 + 's' }"
|
||
>{{ propEmoji(p) }}</view>
|
||
</view>
|
||
|
||
<!-- 角色滑入(左侧) -->
|
||
<view v-if="character" class="theater-char">
|
||
<image v-if="character.image" class="theater-char-img" :src="character.image" mode="aspectFit" />
|
||
<view v-else class="theater-char-avatar" :style="{ background: charColor }">{{ character.name.slice(0, 1) }}</view>
|
||
<view class="theater-char-mood">🤔</view>
|
||
</view>
|
||
|
||
<!-- 旁白字幕(逐字淡入 + 拼音) -->
|
||
<view class="theater-sub card">
|
||
<view class="theater-sub-head">
|
||
<text class="theater-sub-label">📖 情境</text>
|
||
<text class="theater-sub-state">{{ speaking ? '正在朗读…' : '' }}</text>
|
||
</view>
|
||
<view class="theater-sub-ruby">
|
||
<view
|
||
v-for="(ch, i) in pairs"
|
||
:key="i"
|
||
class="theater-unit"
|
||
:style="{ animationDelay: i * 0.05 + 's' }"
|
||
>
|
||
<text v-if="ch.p && showPinyin" class="theater-py">{{ ch.p }}</text>
|
||
<text class="theater-ch">{{ ch.c }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { parsePinyin, zipText } from '../utils/pinyin.js'
|
||
import { propVisual, personVisual } from '../utils/visual.js'
|
||
import { speak, stopSpeak } from '../utils/speech.js'
|
||
|
||
export default {
|
||
name: 'SceneTheater',
|
||
props: {
|
||
sceneName: { type: String, default: '' },
|
||
sceneEmoji: { type: String, default: '🏞️' },
|
||
sceneColor: { type: String, default: '#ffd08a' },
|
||
sceneImage: { type: String, default: '' },
|
||
content: { type: String, default: '' },
|
||
contentPinyin: { type: String, default: '' },
|
||
audio: { type: String, default: '' },
|
||
character: { type: Object, default: null }, // {name, image, id}
|
||
props: { type: Array, default: () => [] }, // 道具名数组
|
||
showPinyin: { type: Boolean, default: true }
|
||
},
|
||
emits: ['done'],
|
||
data() {
|
||
return { speaking: false }
|
||
},
|
||
computed: {
|
||
pairs() {
|
||
return zipText(this.content, parsePinyin(this.contentPinyin))
|
||
},
|
||
charColor() {
|
||
return personVisual(this.character && this.character.id ? this.character.id : 0).color
|
||
}
|
||
},
|
||
mounted() {
|
||
this.speaking = true
|
||
// v2.1:朗读结束自动进入决策幕——最短展示 1.2s,之后 speak onEnd 即进入;
|
||
// 无 TTS/onEnd 缺失最迟 15s 兜底(「跳过」仍可手动跳过)
|
||
this.speechDone = false
|
||
this.ready = false
|
||
let done = false
|
||
const finishAuto = () => {
|
||
if (done) return
|
||
done = true
|
||
this.speaking = false
|
||
this.$emit('done')
|
||
}
|
||
this.minTimer = setTimeout(() => { this.ready = true; if (this.speechDone) finishAuto() }, 1200)
|
||
this.autoTimer = setTimeout(finishAuto, 15000)
|
||
const onSpeechEnd = () => {
|
||
this.speechDone = true
|
||
if (this.ready) finishAuto()
|
||
}
|
||
if (!this.content) {
|
||
finishAuto()
|
||
return
|
||
}
|
||
speak(this.content, this.audio, onSpeechEnd)
|
||
},
|
||
beforeUnmount() {
|
||
clearTimeout(this.minTimer)
|
||
clearTimeout(this.autoTimer)
|
||
stopSpeak()
|
||
},
|
||
methods: {
|
||
finish() {
|
||
stopSpeak()
|
||
this.$emit('done')
|
||
},
|
||
propEmoji(name) {
|
||
return propVisual(name).emoji
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.theater { position: fixed; inset: 0; z-index: 30; padding: 120rpx 48rpx 80rpx; display: flex; flex-direction: column; overflow: hidden; }
|
||
.theater-bg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||
.theater-skip { position: absolute; top: calc(24rpx + env(safe-area-inset-top)); right: 32rpx; font-size: 28rpx; color: #5b4636; background: rgba(255, 255, 255, 0.8); border-radius: 30rpx; padding: 10rpx 28rpx; z-index: 2; }
|
||
.theater-scene { display: flex; align-items: center; justify-content: center; gap: 16rpx; margin-bottom: 48rpx; animation: t-pop 0.6s ease; }
|
||
.theater-emoji { font-size: 88rpx; }
|
||
.theater-name { font-size: 44rpx; font-weight: 800; color: #5b4636; }
|
||
@keyframes t-pop { 0% { opacity: 0; transform: scale(0.5); } 70% { transform: scale(1.1); } 100% { opacity: 1; transform: scale(1); } }
|
||
.theater-props { position: absolute; top: 40%; right: 32rpx; display: flex; flex-direction: column; gap: 24rpx; z-index: 1; }
|
||
.theater-prop { font-size: 64rpx; opacity: 0; animation: prop-fly 0.8s ease forwards; }
|
||
@keyframes prop-fly { 0% { opacity: 0; transform: translateX(140rpx) rotate(40deg); } 70% { transform: translateX(-16rpx) rotate(-10deg); } 100% { opacity: 1; transform: translateX(0) rotate(0); } }
|
||
.theater-char { position: absolute; left: 32rpx; bottom: 36%; z-index: 1; opacity: 0; animation: char-in 0.9s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s forwards; }
|
||
.theater-char-img { width: 180rpx; height: 180rpx; }
|
||
.theater-char-avatar { width: 160rpx; height: 160rpx; border-radius: 50%; color: #fff; font-size: 72rpx; font-weight: 800; display: flex; align-items: center; justify-content: center; }
|
||
.theater-char-mood { position: absolute; right: -16rpx; bottom: -16rpx; font-size: 56rpx; }
|
||
@keyframes char-in { 0% { opacity: 0; transform: translateX(-220rpx); } 70% { transform: translateX(16rpx); } 100% { opacity: 1; transform: translateX(0); } }
|
||
.theater-sub { margin-top: auto; padding: 32rpx 36rpx; position: relative; z-index: 2; }
|
||
.theater-sub-head { display: flex; justify-content: space-between; margin-bottom: 12rpx; }
|
||
.theater-sub-label { font-size: 26rpx; color: #a08c74; font-weight: 700; }
|
||
.theater-sub-state { font-size: 24rpx; color: #ff6b35; }
|
||
.theater-sub-ruby { display: flex; flex-wrap: wrap; line-height: 1.9; }
|
||
.theater-unit { display: flex; flex-direction: column; align-items: center; margin: 0 2rpx; opacity: 0; animation: t-word 0.4s ease forwards; }
|
||
@keyframes t-word { 0% { opacity: 0; transform: translateY(12rpx); } 100% { opacity: 1; transform: translateY(0); } }
|
||
.theater-py { font-size: 20rpx; color: #ff6b35; line-height: 1.3; height: 28rpx; }
|
||
.theater-ch { font-size: 34rpx; line-height: 1.5; color: #5b4636; }
|
||
</style>
|