feat: 开场演出覆盖层组件 SceneTheater(场景横幅/角色滑入/道具飞入/旁白朗读/字幕逐字淡入/跳过)

This commit is contained in:
2026-08-13 15:35:39 +08:00
parent 077a0db0a0
commit cd8b176ef1
+131
View File
@@ -0,0 +1,131 @@
<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 class="theater-actions">
<view class="btn-primary theater-go" @click="finish">开始 </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
speak(this.content, this.audio)
setTimeout(() => { this.speaking = false }, Math.min(3000, this.content.length * 300))
},
beforeUnmount() {
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; }
.theater-actions { display: flex; justify-content: center; margin-top: 32rpx; position: relative; z-index: 2; }
.theater-go { font-size: 34rpx; min-width: 240rpx; animation: t-pop 0.5s ease 1.2s backwards; }
</style>