30 KiB
故事剧场化实现计划(三幕演出 + 动作卡全互动呈现)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 把每关从"文字问答"升级为"儿童小剧场":开场演出(旁白+字幕淡入+角色道具入场)、决策动作卡(替代文字选项列表)、分支演出(选择后动画过渡)、结局演出(庆祝/鼓励动画),决策树数据模型零改动。
Architecture: 纯前端呈现层升级。新增 theater.js(互动形态判定 + 演出数据提取纯函数)、ActionCard.vue(动作卡)、SceneTheater.vue(开场演出覆盖层);改造 play.vue(开场集成 + 动作卡 + 分支演出)与 result.vue(结局演出)。触控互动(PropPick 等 5 种)仅用于已有 config 互动节点;普通决策节点一律用动作卡呈现(计划级修正,见下)。全部 CSS 动画,无新依赖。
Tech Stack: uni-app (Vue 3) H5 先行、现有 RubyText/SpeakButton/speech.js/visual.js/sound.js、CSS keyframes。
对规格的修正(重要):spec 中"按选项内容特征自动判定互动形态 a-e"与现有交互协议冲突——触控互动组件只有 success/fail 双出口(提交 options[0]/options[1]),而普通决策节点的 2-4 个选项各自指向独立分支,前端无法静态判定"最佳选项"。修正:普通决策节点(interaction_type=1)一律渲染动作卡(多分支语义完整保留),触控互动仅用于 config 非空的互动节点。全 36 关文字选项列表消灭的目标不变(动作卡兜底所有普通节点)。
Task 1: ui-src/src/utils/theater.js(互动判定 + 演出数据提取)
Files:
-
Create:
ui-src/src/utils/theater.js -
Step 1: 写文件
// 剧场化工具:互动形态判定 + 开场演出数据提取(决策树模型零改动)
const INTERACTION_MAP = { 2: 'PropPick', 3: 'StepSort', 5: 'DragPlace', 7: 'FindSpot', 8: 'LinkMatch' }
// 互动形态判定:config 互动节点 → 对应触控组件;普通决策节点 → 动作卡
// (触控组件只有 success/fail 双出口,普通节点多分支选项语义由 ActionCard 完整保留)
export function pickInteraction(node) {
if (node && node.config && INTERACTION_MAP[node.interaction_type]) {
return INTERACTION_MAP[node.interaction_type]
}
return 'ActionCard'
}
// 开场演出道具:入口节点选项的道具名去重,最多 4 个
export function entryProps(entry) {
const names = []
for (const o of (entry && entry.options) || []) {
if (o.prop && o.prop.name && !names.includes(o.prop.name)) {
names.push(o.prop.name)
if (names.length >= 4) break
}
}
return names
}
// 开场演出角色:入口节点人物(可能为空)
export function entryCharacter(entry) {
return entry && entry.character ? entry.character : null
}
- Step 2: H5 编译检查
Run: curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/utils/theater.js 返回 200。
- Step 3: 提交
git add ui-src/src/utils/theater.js
git commit -m "feat: 剧场化工具 theater.js(互动形态判定 + 开场演出数据提取)"
Task 2: ui-src/src/components/ActionCard.vue(动作卡组件)
Files:
- Create:
ui-src/src/components/ActionCard.vue
动作卡 = 现有选项列表的升级呈现:每选项一张卡(序号徽章 + 道具图标/emoji + 短语拼音注音 + 听一听 + 按压弹跳 + c1-c4 四色边框)。
- Step 1: 写组件
<template>
<view class="action-cards">
<view
v-for="(o, idx) in node.options"
:key="o.option_id"
class="action-card"
:class="['c' + (idx + 1), { disabled: disabled }]"
@click="pick(o)"
>
<view class="ac-mark">{{ ['A', 'B', 'C', 'D'][idx] }}</view>
<view v-if="o.prop" class="ac-icon">
<image v-if="o.prop.image" class="ac-icon-img" :src="o.prop.image" mode="aspectFit" />
<text v-else class="ac-icon-emoji">{{ propEmoji(o.prop.name) }}</text>
</view>
<view class="ac-text">
<RubyText :text="o.text" :pinyin="o.text_pinyin" :show="showPinyin" />
</view>
<view class="ac-listen" @click.stop="listen(o)">🔊</view>
</view>
</view>
</template>
<script>
import RubyText from './RubyText.vue'
import { propVisual } from '../utils/visual.js'
import { speak } from '../utils/speech.js'
export default {
name: 'ActionCard',
components: { RubyText },
props: {
node: { type: Object, required: true }, // 决策节点(options 数组)
showPinyin: { type: Boolean, default: true },
disabled: { type: Boolean, default: false }
},
emits: ['choose'],
methods: {
pick(o) {
if (this.disabled) return
this.$emit('choose', o)
},
listen(o) {
speak(o.text, o.audio)
},
propEmoji(name) {
return propVisual(name).emoji
}
}
}
</script>
<style scoped>
.action-cards { display: flex; flex-direction: column; gap: 20rpx; }
.action-card { display: flex; align-items: center; background: #f8f2e8; border: 6rpx solid transparent; border-radius: 24rpx; padding: 26rpx 28rpx; transition: transform 0.15s; }
.action-card.disabled { opacity: 0.6; }
.action-card:active { transform: scale(0.96); }
.action-card.c1 { border-color: #ff9a3d; }
.action-card.c2 { border-color: #4ecdc4; }
.action-card.c3 { border-color: #6c8cff; }
.action-card.c4 { border-color: #a58cff; }
.ac-mark { width: 56rpx; height: 56rpx; border-radius: 50%; background: #ff9a3d; color: #fff; font-weight: 800; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
.ac-icon { width: 88rpx; height: 88rpx; background: #fff7ec; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
.ac-icon-img { width: 64rpx; height: 64rpx; }
.ac-icon-emoji { font-size: 48rpx; }
.ac-text { flex: 1; font-size: 30rpx; font-weight: 600; color: #5b4636; }
.ac-listen { font-size: 36rpx; width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; background: #fff0e5; border-radius: 50%; margin-left: 16rpx; flex-shrink: 0; }
</style>
- Step 2: H5 编译检查
Run: curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/ActionCard.vue 返回 200。
- Step 3: 提交
git add ui-src/src/components/ActionCard.vue
git commit -m "feat: 动作卡组件 ActionCard(选项图标/拼音/听读/按压动画/四色边框)"
Task 3: ui-src/src/components/SceneTheater.vue(开场演出覆盖层)
Files:
- Create:
ui-src/src/components/SceneTheater.vue
开场演出:场景横幅 + 角色滑入 + 道具飞入 + 旁白自动朗读 + 字幕逐字淡入 + 跳过按钮。演出播完(或点跳过)后 emit done,父组件隐藏本层。
- Step 1: 写组件
<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>
- Step 2: H5 编译检查
Run: curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/SceneTheater.vue 返回 200。
- Step 3: 提交
git add ui-src/src/components/SceneTheater.vue
git commit -m "feat: 开场演出组件 SceneTheater(旁白朗读+字幕逐字淡入+角色入场+道具飞入+跳过)"
Task 4: play.vue 集成(开场演出 + 动作卡 + 分支演出)
Files:
-
Modify:
ui-src/src/pages/play/play.vue -
Step 1: script 部分改造
<script> 段整体替换为:
<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: ''
}
},
onLoad(options) {
this.levelId = Number(options.level_id || 0)
this.load()
},
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)
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'
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>
- Step 2: template 部分改造
在 <view v-if="detail" class="content"> 之前插入开场演出覆盖层,并把选项列表块替换为 ActionCard:
开场覆盖层(插在 </view>(topbar 结束)之后、<view v-if="detail" class="content"> 之前):
<!-- 开场演出(覆盖层,播完/跳过进入决策) -->
<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"
/>
场景卡片加演出状态类(.scene-card 增加 :class="{ 'fx-shake': sceneFx }"):
<view class="scene-card card" :class="{ 'fx-shake': sceneFx }" :style="{ background: scene.color + '44' }">
互动分发整块替换(原「互动组件(触控 5 种)」v-if 链 + 「选项选择(type 1)」v-else-if 的 class="options" 列表 + 「soon 兜底」三段,替换为 activeInteraction 驱动):
<!-- 互动组件(触控 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>
- Step 3: style 部分追加
在 <style scoped> 末尾(.fb-continue 规则后)追加:
.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); } }
- Step 4: H5 编译检查
Run: curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/pages/play/play.vue 返回 200。
- Step 5: 提交
git add ui-src/src/pages/play/play.vue
git commit -m "feat: 闯关页剧场化(开场演出覆盖层+动作卡呈现+分支演出晃动过渡)"
审查后修复(b48bb41 双审查 Approved,NIT 合并修复):
- 删除残留死样式
.options/.option/.option-mark/.option-text/.option.disabled/.option:active(模板已由 ActionCard 接管) - 分支演出
setTimeout句柄存this.branchTimer,页面onUnload()清理(防卸载后回调写状态) submit幽灵步修复:routeSteps.push改为if (data.next || data.final)时才 push(API 异常无返回时不污染进度点)- ActionCard
listen()加if (this.disabled) return守卫(演出 600ms 期间不可点听读,防音效重叠)
Task 5: result.vue 结局演出(彩带 + 动态标题 + 鼓励语)
Files:
-
Modify:
ui-src/src/pages/result/result.vue -
Step 1: script 部分改造
<script> 整体替换为:
<script>
import { sceneVisual } from '../../utils/visual.js'
import { playSound } from '../../utils/sound.js'
const RESULT_KEY = '36wisdom_result'
export default {
data() {
return { result: null, pieces: [] }
},
onLoad() {
const saved = uni.getStorageSync(RESULT_KEY)
this.result = saved || null
if (!saved) {
uni.reLaunch({ url: '/pages/map/map' })
return
}
this.pieces = this.makePieces(30)
playSound(this.result.final.stars > 0 ? 'finish' : 'bad')
},
computed: {
stars() {
return this.result.final.stars
},
title() {
if (this.stars >= 3) return '完美通关!'
if (this.stars > 0) return '通关啦!'
return '再试一次!'
},
sceneEmoji() {
return sceneVisual(this.result.scene_name).emoji
},
encourage() {
if (this.stars === 0) return '你已经很棒了,再试一次会更好!'
if (this.stars < 3) return '还差一点点,补完分支就能完美!'
return ''
}
},
methods: {
replay() {
uni.redirectTo({ url: '/pages/play/play?level_id=' + this.result.level_id })
},
backMap() {
uni.reLaunch({ url: '/pages/map/map' })
},
makePieces(n) {
const colors = ['#ff6b35', '#ffb347', '#4ecdc4', '#6c8cff', '#a58cff', '#ff6b9a']
const list = []
for (let i = 0; i < n; i++) {
list.push({
left: Math.random() * 100,
delay: Math.random() * 1.5,
duration: 2 + Math.random() * 2,
color: colors[i % colors.length],
rotate: Math.random() * 360
})
}
return list
}
}
}
</script>
- Step 2: template 部分改造
<template> 整体替换为:
<template>
<view class="page">
<!-- 彩带(通关/完美时) -->
<view v-if="result && stars > 0" class="confetti">
<view
v-for="(p, i) in pieces"
:key="i"
class="confetti-piece"
:style="{ left: p.left + '%', background: p.color, animationDelay: p.delay + 's', animationDuration: p.duration + 's', transform: 'rotate(' + p.rotate + 'deg)' }"
></view>
</view>
<view v-if="result" class="result">
<view class="result-emoji">{{ sceneEmoji }}</view>
<view class="result-title" :class="{ great: stars >= 3, ok: stars > 0 && stars < 3, again: stars === 0 }">{{ title }}</view>
<view class="stars-area">
<view
v-for="i in 3"
:key="i"
class="big-star"
:class="{ on: i <= stars, pop: i <= stars }"
:style="{ animationDelay: i * 0.35 + 's' }"
>★</view>
</view>
<view v-if="encourage" class="encourage">💪 {{ encourage }}</view>
<view class="card panel">
<view class="panel-row">
<text class="label">关卡</text>
<text class="value">{{ result.title }}</text>
</view>
<view class="panel-row">
<text class="label">积分</text>
<text class="value delta" :class="result.final.score_delta >= 0 ? 'up' : 'down'">
{{ result.final.score_delta >= 0 ? '+' : '' }}{{ result.final.score_delta }}
</text>
</view>
<view class="panel-row">
<text class="label">当前余额</text>
<text class="value">{{ result.final.balance_after }}</text>
</view>
</view>
<view v-if="result.final.perfect" class="badge perfect">✨ 完美通关!</view>
<view v-if="result.final.collection_unlocked" class="badge card-collect">🃏 获得计策卡</view>
<view v-if="result.final.unlock_next" class="badge next-unlock">🔓 下一计已解锁</view>
<view v-if="result.final.new_level > 0" class="badge level-up">🎉 成长等级提升!</view>
<view class="actions">
<view class="btn-primary action" @click="replay">再玩一次</view>
<view class="btn-primary action ghost" @click="backMap">返回地图</view>
</view>
</view>
</view>
</template>
- Step 3: style 部分追加
<style scoped> 末尾追加(保留原有所有规则):
.result-emoji { font-size: 96rpx; margin-bottom: 8rpx; animation: t-pop 0.6s ease; }
.result-title.great { color: #ffb347; }
.result-title.ok { color: #ff6b35; }
.result-title.again { color: #6c8cff; }
.encourage { font-size: 30rpx; color: #7a6248; background: #fff0e5; border-radius: 30rpx; padding: 16rpx 32rpx; margin-bottom: 32rpx; display: inline-block; }
@keyframes t-pop { 0% { opacity: 0; transform: scale(0.5); } 70% { transform: scale(1.1); } 100% { opacity: 1; transform: scale(1); } }
.confetti { position: fixed; inset: 0; pointer-events: none; overflow: hidden; z-index: 5; }
.confetti-piece { position: absolute; top: -40rpx; width: 16rpx; height: 28rpx; border-radius: 4rpx; opacity: 0; animation: confetti-fall linear forwards; }
@keyframes confetti-fall { 0% { opacity: 1; transform: translateY(0) rotate(0); } 100% { opacity: 0.7; transform: translateY(110vh) rotate(720deg); } }
注意:
@keyframes t-pop已在 Task 3 的 SceneTheater 内定义(scoped 隔离,互不冲突);若本文件重复定义同名 keyframes 无碍(各文件 scoped)。
- Step 4: H5 编译检查
Run: curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/pages/result/result.vue 返回 200。
- Step 5: 提交
git add ui-src/src/pages/result/result.vue
git commit -m "feat: 结算页结局演出(彩带+动态标题+鼓励语+场景 emoji)"
Task 6: 文档更新 + 端到端走查 + 提交
Files:
-
Modify:
README.md(情境闯关行) -
Modify:
技术设计.md(4.13 游戏化 UI 设计补充三幕剧场化) -
Step 1: README「情境闯关」行补充
README.md 功能模块表中「情境闯关」行末尾追加(保持现有行文风格):
呈现为三幕小剧场:开场演出(旁白朗读+字幕逐字淡入+角色入场+道具飞入,可跳过)→ 决策演出(普通节点为动作卡——图标/拼音/听读/按压动画,config 互动节点为触控互动,选择后分支演出过渡再弹对比点评)→ 结局演出(成功彩带/失败鼓励语)
- Step 2: 技术设计.md 4.13 补充
技术设计.md 的「4.13 游戏化 UI 设计」小节末尾追加「三幕剧场化」说明(含:开场演出覆盖层 SceneTheater、动作卡 ActionCard、分支演出 0.6s 晃动过渡、结局彩带 CSS 粒子;互动形态判定:config 互动节点用触控组件,普通决策节点一律动作卡——理由:触控组件 success/fail 双出口与多分支选项语义不兼容)。
- Step 3: 后端回归 + 全量编译
Run: go build ./... && go vet ./... && go test ./... 全部通过(无后端改动,确认无回归)。
- Step 4: H5 走查(浏览器人工)
Run: curl -s http://localhost:5173/ 200(dev server 已在跑)。浏览器打开 http://localhost:5173 走查:
- 进入任意关卡 → 开场演出自动播放(旁白朗读、字幕逐字淡入、角色滑入、道具飞入),点「跳过」立即进入
- 决策节点显示动作卡(图标/拼音/听读/四色边框),点击有按压动画
- 选择后场景晃动 0.6s 再弹点评(你的选择 + 好处/坏处)
- 走完分支到结算 → 通关显示彩带 + 庆祝标题;故意走错到未通关 → 鼓励语
- 拼音开关、声音开关、进度点正常
- Step 5: 提交
git add README.md 技术设计.md
git commit -m "docs: 三幕剧场化设计(README 情境闯关行 + 技术设计 4.13 补充)"
计划外可选(本次不实施)
- 36 关入口互动数据补全(种子补道具选项):触控互动覆盖面由后台配置扩展,非本计划范围
- genasset 角色表情帧/动作帧素材(阶段 C):依赖 Task 10 素材管线完成后另行计划