feat(v2.2): 剧情纯语音演绎 + 单端口 8080 托管前端
- StoryPlayer 移除文字展示区,改为纯语音逐句推进(角色+表情+跳过)
- 每句 speak 追加估时兜底(onEnd 缺失时按文本长度放行,不卡死)
- SceneTheater 开场按文本估时自动进入(兜底 15s → max(4000, len*320+2000))
- main.go SetServerRoot 托管 ui-src/dist(AddStaticPath("/") 前缀守卫只服务根路径),
/static 前缀挂载 ui-src/static 素材目录,前后端同端口 8080
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -91,7 +91,8 @@ export default {
|
||||
this.$emit('done')
|
||||
}
|
||||
this.minTimer = setTimeout(() => { this.ready = true; if (this.speechDone) finishAuto() }, 1200)
|
||||
this.autoTimer = setTimeout(finishAuto, 15000)
|
||||
// 兜底按文本长度估时(onEnd 缺失时按朗读预期时长自动进入,不卡 15s)
|
||||
this.autoTimer = setTimeout(finishAuto, Math.max(4000, this.content.length * 320 + 2000))
|
||||
const onSpeechEnd = () => {
|
||||
this.speechDone = true
|
||||
if (this.ready) finishAuto()
|
||||
|
||||
@@ -7,43 +7,27 @@
|
||||
<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" v-show="i === lineIndex || i === lineIndex - 1" class="story-line" :class="{ active: i === lineIndex, fading: i === lineIndex - 1 }">
|
||||
<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>
|
||||
<!-- v2.2 纯语音演绎:无文字展示区,speak 逐句推进 -->
|
||||
</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 // 句间停顿
|
||||
const MAX_LINE_MS = 20000 // 单句最长等待(TTS 无声兜底,防卡死)
|
||||
const MAX_LINE_MS = 20000 // 单句最长等待(估时兜底也失效时强制推进)
|
||||
|
||||
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 }
|
||||
character: { type: Object, default: null } // {name, image, id}
|
||||
},
|
||||
emits: ['done'],
|
||||
data() {
|
||||
return { lineIndex: 0, charIndex: 0, moodTick: false, timers: [], ended: false, lineDone: { hl: false, sp: false } }
|
||||
return { lineIndex: 0, moodTick: false, timers: [], ended: false, lineDone: { sp: false, fb: false } }
|
||||
},
|
||||
computed: {
|
||||
moodEmoji() {
|
||||
@@ -76,33 +60,26 @@ export default {
|
||||
return
|
||||
}
|
||||
this.lineIndex = i
|
||||
this.charIndex = 0
|
||||
this.ended = false
|
||||
this.lineDone = { hl: false, sp: false }
|
||||
this.lineDone = { sp: false, fb: false }
|
||||
this.moodTick = true
|
||||
this.after(350, () => { this.moodTick = false })
|
||||
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.lineDone.hl = true
|
||||
this.tryNext(i)
|
||||
}
|
||||
}, PER_CHAR)
|
||||
this.timers.push(step)
|
||||
// 朗读读完才进下一句(TTS 慢时高亮等朗读);onEnd 与高亮都完成 → 停顿后下一句
|
||||
speak(line.text, '', () => {
|
||||
this.lineDone.sp = true
|
||||
this.tryNext(i)
|
||||
})
|
||||
// 兜底:TTS 无声/超时(20s)强制推进防卡死
|
||||
// 估时兜底:onEnd 缺失(浏览器静音策略/无语音)时按文本长度估时放行,不卡死
|
||||
this.after(Math.max(2500, total * 320 + 1200), () => {
|
||||
if (!this.lineDone.sp) {
|
||||
this.lineDone.sp = true
|
||||
this.tryNext(i)
|
||||
}
|
||||
})
|
||||
this.after(MAX_LINE_MS, () => this.forceNext(i))
|
||||
},
|
||||
tryNext(i) {
|
||||
if (this.ended || this.lineIndex !== i || !this.lineDone.hl || !this.lineDone.sp) return
|
||||
if (this.ended || this.lineIndex !== i || !this.lineDone.sp) return
|
||||
this.ended = true
|
||||
this.clearTimers()
|
||||
this.after(PAUSE_MS, () => { this.ended = false; this.playLine(i + 1) })
|
||||
@@ -133,14 +110,4 @@ export default {
|
||||
.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.fading { opacity: 0.35; }
|
||||
.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>
|
||||
|
||||
@@ -47,12 +47,12 @@
|
||||
|
||||
<!-- v2.1 终局演绎:结局台词播完自动跳结算 -->
|
||||
<view v-if="playingFinal && finalNode" class="node story-node final-story">
|
||||
<StoryPlayer :key="'final-' + finalNode.node_id" :script="finalScript" :character="finalNode.character" :show-pinyin="showPinyin" @done="gotoResult" />
|
||||
<StoryPlayer :key="'final-' + finalNode.node_id" :script="finalScript" :character="finalNode.character" @done="gotoResult" />
|
||||
</view>
|
||||
|
||||
<!-- 节点:剧情流(script 非空)或 v1 卡片(回退);终局演绎时隐藏 -->
|
||||
<view v-if="curNode && isStoryNode && !playingFinal" class="node story-node">
|
||||
<StoryPlayer :key="curNode.node_id" :script="storyScript" :character="curNode.character" :show-pinyin="showPinyin" @done="showOptions = true" />
|
||||
<StoryPlayer :key="curNode.node_id" :script="storyScript" :character="curNode.character" @done="showOptions = true" />
|
||||
<view v-if="showOptions" class="story-options">
|
||||
<PropPick v-if="activeInteraction === 'PropPick'" :config="curNode.config" @done="interactionDone" />
|
||||
<FindSpot v-else-if="activeInteraction === 'FindSpot'" :config="curNode.config" @done="interactionDone" />
|
||||
|
||||
Reference in New Issue
Block a user