feat: play.vue 剧情流集成——StoryPlayer + 选项嵌入 + 角色回应气泡(script 空回退 v1)

This commit is contained in:
2026-08-13 16:20:12 +08:00
parent 3e09457b20
commit 5e191747ab
+70 -15
View File
@@ -45,8 +45,39 @@
<SpeakButton class="scene-speak" :text="detail.scene_content" :file="detail.scene_audio" label="读一读情境" />
</view>
<!-- 节点互动分发 -->
<view v-if="curNode" class="node card">
<!-- 节点剧情流script 非空 v1 卡片回退 -->
<view v-if="curNode && isStoryNode" class="node story-node">
<StoryPlayer :script="storyScript" :character="curNode.character" :show-pinyin="showPinyin" @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" />
<DragPlace v-else-if="activeInteraction === 'DragPlace'" :config="curNode.config" @done="interactionDone" />
<StepSort v-else-if="activeInteraction === 'StepSort'" :config="curNode.config" @done="interactionDone" />
<LinkMatch v-else-if="activeInteraction === 'LinkMatch'" :config="curNode.config" @done="interactionDone" />
<ActionCard
v-else-if="activeInteraction === 'ActionCard'"
:node="curNode"
:show-pinyin="showPinyin"
:disabled="choosing"
@choose="choose"
/>
<view v-else class="soon card">该互动玩法类型 {{ curNode.interaction_type }}敬请期待</view>
</view>
<!-- 角色回应气泡融入剧情流替代弹窗 -->
<view v-if="reply" class="story-reply card">
<view class="reply-char">🧑🎓 你的选择</view>
<view class="fb-option"><RubyText :text="reply.text" :pinyin="reply.textPinyin" :show="showPinyin" /></view>
<view v-if="reply.pros" class="fb-line fb-pros fb-in"><text class="fb-mark"></text>{{ reply.pros }}</view>
<view v-if="reply.cons" class="fb-line fb-cons fb-in d2"><text class="fb-mark"></text>{{ reply.cons }}</view>
<view v-if="reply.prop" class="fb-prop">
<view class="fb-prop-name">{{ reply.prop.name }}</view>
<view v-if="reply.prop.description" class="fb-prop-desc">{{ reply.prop.description }}</view>
</view>
<SpeakButton :text="reply.text" :file="reply.audio" label="听点评" />
<view class="btn-primary fb-continue" @click="continuePlay">继续 </view>
</view>
</view>
<view v-else-if="curNode" class="node card">
<view class="node-title">{{ curNode.title }}</view>
<view v-if="curNode.character" class="character">
<view class="char-card">
@@ -106,10 +137,11 @@ import { api } from '../../api/index.js'
import { getChildId, getChildAge } from '../../store/user.js'
import { sceneVisual, personVisual } from '../../utils/visual.js'
import { playSound, isSoundEnabled, setSoundEnabled } from '../../utils/sound.js'
import { pickInteraction, entryProps, entryCharacter } from '../../utils/theater.js'
import { pickInteraction, entryProps, entryCharacter, parseScript } from '../../utils/theater.js'
import RubyText from '../../components/RubyText.vue'
import SpeakButton from '../../components/SpeakButton.vue'
import SceneTheater from '../../components/SceneTheater.vue'
import StoryPlayer from '../../components/StoryPlayer.vue'
import ActionCard from '../../components/ActionCard.vue'
import PropPick from '../../components/interactions/PropPick.vue'
import FindSpot from '../../components/interactions/FindSpot.vue'
@@ -120,7 +152,7 @@ import LinkMatch from '../../components/interactions/LinkMatch.vue'
const RESULT_KEY = '36wisdom_result'
export default {
components: { RubyText, SpeakButton, SceneTheater, ActionCard, PropPick, FindSpot, DragPlace, StepSort, LinkMatch },
components: { RubyText, SpeakButton, SceneTheater, StoryPlayer, ActionCard, PropPick, FindSpot, DragPlace, StepSort, LinkMatch },
data() {
return {
childId: getChildId(),
@@ -128,6 +160,8 @@ export default {
detail: null,
curNode: null,
feedback: null,
showOptions: false,
reply: null,
choosing: false,
showPinyin: getChildAge() === '4-6',
routeSteps: [],
@@ -162,6 +196,12 @@ export default {
activeInteraction() {
return this.curNode ? pickInteraction(this.curNode) : ''
},
storyScript() {
return this.curNode ? parseScript(this.curNode.script) : null
},
isStoryNode() {
return !!this.storyScript
},
introCharacter() {
return this.detail ? entryCharacter(this.detail.entry) : null
},
@@ -210,18 +250,23 @@ export default {
this.mood = o.feedback_cons ? '😢' : '😊'
this.combo = o.feedback_cons ? 0 : Math.min(this.combo + 1, 9)
playSound(o.feedback_cons ? 'bad' : 'good')
// 分支演出:场景晃动 + 0.6s 后弹点评
// 分支演出:场景晃动 + 0.6s 后出反馈(剧情流=回应气泡,v1=弹窗)
this.sceneFx = 'fx-shake'
const fb = {
text: o.text,
textPinyin: o.text_pinyin,
prop: o.prop,
audio: o.audio,
pros: o.feedback_pros,
cons: o.feedback_cons,
next: data.next
}
this.branchTimer = setTimeout(() => {
this.sceneFx = ''
this.feedback = {
text: o.text,
textPinyin: o.text_pinyin,
prop: o.prop,
audio: o.audio,
pros: o.feedback_pros,
cons: o.feedback_cons,
next: data.next
if (this.isStoryNode) {
this.reply = fb
} else {
this.feedback = fb
}
this.choosing = false
}, 600)
@@ -243,8 +288,14 @@ export default {
}
},
continuePlay() {
this.curNode = this.feedback.next
this.feedback = null
if (this.isStoryNode) {
this.curNode = this.reply.next
this.reply = null
this.showOptions = false
} else {
this.curNode = this.feedback.next
this.feedback = null
}
this.mood = '🤔'
}
}
@@ -304,6 +355,10 @@ export default {
.fb-prop-name { font-size: 30rpx; font-weight: 700; color: #7a6248; margin-bottom: 8rpx; }
.fb-prop-desc { font-size: 26rpx; line-height: 1.7; color: #a08c74; }
.fb-continue { margin-top: 24rpx; font-size: 30rpx; }
.story-node { padding: 32rpx 28rpx 48rpx; }
.story-options { margin-top: 8rpx; }
.story-reply { margin-top: 24rpx; background: #fffdf6; animation: fb-in 0.4s ease; }
.reply-char { font-size: 26rpx; color: #a08c74; margin-bottom: 16rpx; font-weight: 700; }
.fx-shake { animation: fx-shake 0.5s ease; }
@keyframes fx-shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-12rpx); } 50% { transform: translateX(10rpx); } 75% { transform: translateX(-6rpx); } }
</style>