310 lines
15 KiB
Vue
310 lines
15 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view class="topbar">
|
||
<view class="back" @click="goBack">‹</view>
|
||
<view class="top-title">{{ detail ? detail.title : '' }}</view>
|
||
<view class="top-actions">
|
||
<view class="py-toggle" @click="showPinyin = !showPinyin">{{ showPinyin ? '拼音开' : '拼音关' }}</view>
|
||
<view class="sound-toggle" @click="toggleSound">{{ soundOn ? '🔊' : '🔇' }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 开场演出(覆盖层,播完/跳过进入决策) -->
|
||
<SceneTheater
|
||
v-if="showIntro && detail"
|
||
:scene-name="detail.scene ? detail.scene.name : ''"
|
||
:scene-emoji="scene.emoji"
|
||
:scene-color="scene.color"
|
||
:scene-image="detail.scene_image"
|
||
:content="detail.scene_content"
|
||
:content-pinyin="detail.scene_content_pinyin"
|
||
:audio="detail.scene_audio"
|
||
:character="introCharacter"
|
||
:props="introProps"
|
||
:show-pinyin="showPinyin"
|
||
@done="showIntro = false"
|
||
/>
|
||
|
||
<view v-if="detail" class="content">
|
||
<!-- 进度点:每走一步点亮一个 -->
|
||
<view class="progress-dots">
|
||
<view v-for="i in 6" :key="i" class="dot" :class="{ on: i <= stepCount }"></view>
|
||
</view>
|
||
|
||
<!-- 场景面板:环境 + 情境描述 + 朗读 + 漂浮装饰 -->
|
||
<view class="scene-card card" :class="{ 'fx-shake': sceneFx }" :style="{ background: scene.color + '44' }">
|
||
<image v-if="sceneImg" class="scene-img" :src="sceneImg" mode="aspectFill" />
|
||
<view class="scene-float f1">☁️</view>
|
||
<view class="scene-float f2">⭐</view>
|
||
<view class="scene-head">
|
||
<view class="scene-emoji">{{ scene.emoji }}</view>
|
||
<view class="scene-name">{{ detail.scene ? detail.scene.name : '' }}</view>
|
||
<view v-if="combo > 1" class="combo-badge">🔥 ×{{ combo }}</view>
|
||
</view>
|
||
<RubyText class="scene-text" :text="detail.scene_content" :pinyin="detail.scene_content_pinyin" :show="showPinyin" />
|
||
<SpeakButton class="scene-speak" :text="detail.scene_content" :file="detail.scene_audio" label="读一读情境" />
|
||
</view>
|
||
|
||
<!-- 节点:互动分发 -->
|
||
<view v-if="curNode" class="node card">
|
||
<view class="node-title">{{ curNode.title }}</view>
|
||
<view v-if="curNode.character" class="character">
|
||
<view class="char-card">
|
||
<image v-if="charImg" class="char-img" :src="charImg" mode="aspectFit" />
|
||
<view v-else class="char-avatar" :style="{ background: personColor(curNode.character.id) }">{{ curNode.character.name.slice(0, 1) }}</view>
|
||
<view class="char-mood">{{ mood }}</view>
|
||
</view>
|
||
<view class="char-info">
|
||
<view class="char-name">{{ curNode.character.name }}</view>
|
||
<SpeakButton :text="curNode.character.name" :file="curNode.character.audio" label="读一读" />
|
||
</view>
|
||
</view>
|
||
<view class="node-content">
|
||
<RubyText :text="curNode.content" :pinyin="curNode.content_pinyin" :show="showPinyin" />
|
||
</view>
|
||
<SpeakButton class="node-speak" :text="curNode.content" :file="curNode.audio" label="读一读" />
|
||
|
||
<!-- 互动组件(触控 5 种,config 互动节点) -->
|
||
<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>
|
||
|
||
<!-- 反馈面板:你的选择 + 好处 + 对比(对比点评三段式) -->
|
||
<view v-if="feedback" class="mask">
|
||
<view class="feedback card">
|
||
<view class="fb-title">你的选择</view>
|
||
<view class="fb-option"><RubyText :text="feedback.text" :pinyin="feedback.textPinyin" :show="showPinyin" /></view>
|
||
<view v-if="feedback.pros" class="fb-line fb-pros fb-in"><text class="fb-mark">✓</text>{{ feedback.pros }}</view>
|
||
<view v-if="feedback.cons" class="fb-line fb-cons fb-in d2"><text class="fb-mark">✗</text>{{ feedback.cons }}</view>
|
||
<view v-if="feedback.prop" class="fb-prop">
|
||
<view class="fb-prop-name">{{ feedback.prop.name }}</view>
|
||
<view v-if="feedback.prop.description" class="fb-prop-desc">{{ feedback.prop.description }}</view>
|
||
</view>
|
||
<SpeakButton :text="feedback.text" :file="feedback.audio" label="听点评" />
|
||
<view class="btn-primary fb-continue" @click="continuePlay">继续 ›</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
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 RubyText from '../../components/RubyText.vue'
|
||
import SpeakButton from '../../components/SpeakButton.vue'
|
||
import SceneTheater from '../../components/SceneTheater.vue'
|
||
import ActionCard from '../../components/ActionCard.vue'
|
||
import PropPick from '../../components/interactions/PropPick.vue'
|
||
import FindSpot from '../../components/interactions/FindSpot.vue'
|
||
import DragPlace from '../../components/interactions/DragPlace.vue'
|
||
import StepSort from '../../components/interactions/StepSort.vue'
|
||
import LinkMatch from '../../components/interactions/LinkMatch.vue'
|
||
|
||
const RESULT_KEY = '36wisdom_result'
|
||
|
||
export default {
|
||
components: { RubyText, SpeakButton, SceneTheater, ActionCard, PropPick, FindSpot, DragPlace, StepSort, LinkMatch },
|
||
data() {
|
||
return {
|
||
childId: getChildId(),
|
||
levelId: 0,
|
||
detail: null,
|
||
curNode: null,
|
||
feedback: null,
|
||
choosing: false,
|
||
showPinyin: getChildAge() === '4-6',
|
||
routeSteps: [],
|
||
mood: '🤔',
|
||
combo: 0,
|
||
soundOn: isSoundEnabled(),
|
||
showIntro: false,
|
||
sceneFx: '',
|
||
branchTimer: null
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
this.levelId = Number(options.level_id || 0)
|
||
this.load()
|
||
},
|
||
onUnload() {
|
||
if (this.branchTimer) clearTimeout(this.branchTimer)
|
||
},
|
||
computed: {
|
||
scene() {
|
||
return sceneVisual(this.detail && this.detail.scene ? this.detail.scene.name : '')
|
||
},
|
||
sceneImg() {
|
||
return this.detail && this.detail.scene && this.detail.scene.image ? this.detail.scene.image : ''
|
||
},
|
||
charImg() {
|
||
return this.curNode && this.curNode.character && this.curNode.character.image ? this.curNode.character.image : ''
|
||
},
|
||
stepCount() {
|
||
return Math.min(this.routeSteps.length + 1, 6)
|
||
},
|
||
activeInteraction() {
|
||
return this.curNode ? pickInteraction(this.curNode) : ''
|
||
},
|
||
introCharacter() {
|
||
return this.detail ? entryCharacter(this.detail.entry) : null
|
||
},
|
||
introProps() {
|
||
return this.detail ? entryProps(this.detail.entry) : []
|
||
}
|
||
},
|
||
methods: {
|
||
async load() {
|
||
try {
|
||
const data = await api.levelDetail(this.childId, this.levelId)
|
||
this.detail = data
|
||
this.curNode = data.entry
|
||
this.showIntro = true
|
||
} catch (e) {
|
||
setTimeout(() => this.goBack(), 800)
|
||
}
|
||
},
|
||
goBack() {
|
||
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/map/map' }) })
|
||
},
|
||
personColor(id) {
|
||
return personVisual(id).color
|
||
},
|
||
// 触控互动判定完成:提交成功/失败出口选项
|
||
interactionDone({ success }) {
|
||
this.mood = success ? '😊' : '😢'
|
||
const opt = success ? this.curNode.options[0] : this.curNode.options[1]
|
||
if (opt) this.submit(opt)
|
||
},
|
||
toggleSound() {
|
||
this.soundOn = !this.soundOn
|
||
setSoundEnabled(this.soundOn)
|
||
},
|
||
choose(o) {
|
||
if (this.choosing) return
|
||
playSound('click')
|
||
this.submit(o)
|
||
},
|
||
async submit(o) {
|
||
this.choosing = true
|
||
try {
|
||
const data = await api.levelChoose(this.childId, this.levelId, this.curNode.node_id, o.option_id)
|
||
if (data.next || data.final) this.routeSteps.push({ text: o.text, pros: o.feedback_pros || '' })
|
||
if (data.next) {
|
||
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 后弹点评
|
||
this.sceneFx = 'fx-shake'
|
||
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
|
||
}
|
||
this.choosing = false
|
||
}, 600)
|
||
} else if (data.final) {
|
||
uni.setStorageSync(RESULT_KEY, {
|
||
level_id: this.levelId,
|
||
title: this.detail.title,
|
||
scene_name: this.detail.scene ? this.detail.scene.name : '',
|
||
route_steps: this.routeSteps,
|
||
final: data.final
|
||
})
|
||
playSound('finish')
|
||
uni.redirectTo({ url: '/pages/result/result' })
|
||
} else {
|
||
this.choosing = false
|
||
}
|
||
} catch (e) {
|
||
this.choosing = false
|
||
}
|
||
},
|
||
continuePlay() {
|
||
this.curNode = this.feedback.next
|
||
this.feedback = null
|
||
this.mood = '🤔'
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page { min-height: 100vh; display: flex; flex-direction: column; background: #fdf6ec; }
|
||
.topbar { display: flex; align-items: center; padding: 24rpx 32rpx; padding-top: calc(24rpx + env(safe-area-inset-top)); }
|
||
.back { font-size: 56rpx; color: #ff6b35; width: 72rpx; height: 72rpx; display: flex; align-items: center; }
|
||
.top-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 700; color: #5b4636; }
|
||
.top-actions { display: flex; align-items: center; gap: 16rpx; }
|
||
.py-toggle { font-size: 24rpx; color: #ff6b35; background: #fff0e5; border-radius: 30rpx; padding: 8rpx 20rpx; }
|
||
.sound-toggle { font-size: 32rpx; background: #fff0e5; border-radius: 50%; width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; }
|
||
.content { flex: 1; padding: 24rpx 32rpx 60rpx; }
|
||
.progress-dots { display: flex; justify-content: center; gap: 16rpx; margin-bottom: 24rpx; }
|
||
.dot { width: 20rpx; height: 20rpx; border-radius: 50%; background: #e5d9c8; transition: all 0.3s; }
|
||
.dot.on { background: #ff9a3d; transform: scale(1.25); box-shadow: 0 0 0 6rpx rgba(255, 154, 61, 0.2); }
|
||
.scene-card { padding: 32rpx; margin-bottom: 24rpx; position: relative; overflow: hidden; }
|
||
.scene-img { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 24rpx; }
|
||
.scene-float { position: absolute; font-size: 44rpx; opacity: 0.85; animation: float-y 3.5s ease-in-out infinite; pointer-events: none; }
|
||
.scene-float.f1 { top: 16rpx; right: 28rpx; }
|
||
.scene-float.f2 { bottom: 20rpx; left: 24rpx; animation-delay: 1.2s; }
|
||
@keyframes float-y { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-14rpx); } }
|
||
.scene-head { display: flex; align-items: center; margin-bottom: 16rpx; position: relative; z-index: 1; }
|
||
.scene-emoji { font-size: 64rpx; margin-right: 20rpx; }
|
||
.scene-name { font-size: 32rpx; font-weight: 800; color: #5b4636; }
|
||
.combo-badge { margin-left: auto; background: #ff6b35; color: #fff; font-weight: 800; border-radius: 30rpx; padding: 8rpx 24rpx; font-size: 28rpx; animation: combo-pop 0.4s ease; }
|
||
@keyframes combo-pop { 0% { transform: scale(0.4); } 60% { transform: scale(1.2); } 100% { transform: scale(1); } }
|
||
.scene-text { margin-bottom: 20rpx; position: relative; z-index: 1; }
|
||
.scene-speak { margin-top: 8rpx; position: relative; z-index: 1; }
|
||
.node { padding: 48rpx 40rpx; }
|
||
.node-title { font-size: 38rpx; font-weight: 800; color: #ff6b35; margin-bottom: 24rpx; }
|
||
.character { display: flex; align-items: center; margin-bottom: 24rpx; }
|
||
.char-card { position: relative; margin-right: 20rpx; flex-shrink: 0; }
|
||
.char-img { width: 96rpx; height: 96rpx; border-radius: 50%; background: #f8f2e8; }
|
||
.char-avatar { width: 96rpx; height: 96rpx; border-radius: 50%; color: #fff; font-size: 40rpx; font-weight: 700; display: flex; align-items: center; justify-content: center; }
|
||
.char-mood { position: absolute; right: -8rpx; bottom: -8rpx; font-size: 36rpx; background: #fff; border-radius: 50%; width: 48rpx; height: 48rpx; display: flex; align-items: center; justify-content: center; box-shadow: 0 2rpx 8rpx rgba(91, 70, 54, 0.2); animation: mood-pop 0.3s ease; }
|
||
@keyframes mood-pop { 0% { transform: scale(0.4); } 100% { transform: scale(1); } }
|
||
.char-info { display: flex; flex-direction: column; gap: 8rpx; }
|
||
.char-name { font-size: 30rpx; font-weight: 700; color: #7a6248; }
|
||
.node-content { font-size: 32rpx; line-height: 1.9; color: #5b4636; margin-bottom: 24rpx; }
|
||
.node-speak { margin-bottom: 32rpx; }
|
||
.soon { padding: 40rpx; text-align: center; color: #a08c74; background: #f8f2e8; }
|
||
.mask { position: fixed; inset: 0; background: rgba(91, 70, 54, 0.5); display: flex; align-items: center; justify-content: center; z-index: 10; }
|
||
.feedback { width: 82%; padding: 48rpx 40rpx; }
|
||
.fb-title { font-size: 26rpx; color: #a08c74; margin-bottom: 16rpx; }
|
||
.fb-option { font-size: 34rpx; font-weight: 800; color: #ff6b35; margin-bottom: 24rpx; }
|
||
.fb-line { font-size: 28rpx; line-height: 1.7; border-radius: 16rpx; padding: 16rpx 24rpx; margin-bottom: 16rpx; }
|
||
.fb-in { opacity: 0; animation: fb-in 0.4s ease forwards; }
|
||
.fb-in.d2 { animation-delay: 0.2s; }
|
||
@keyframes fb-in { from { opacity: 0; transform: translateY(16rpx); } to { opacity: 1; transform: translateY(0); } }
|
||
.fb-pros { background: #e8f7f3; color: #1fa98f; }
|
||
.fb-cons { background: #fff0e5; color: #e07b39; }
|
||
.fb-mark { font-weight: 800; margin-right: 8rpx; }
|
||
.fb-prop { background: #fff7ec; border-radius: 20rpx; padding: 24rpx 28rpx; margin-bottom: 32rpx; }
|
||
.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; }
|
||
.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>
|