Files
36Wisdom/ui-src/src/pages/play/play.vue
T

310 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>