Files
36Wisdom/docs/superpowers/plans/2026-08-13-story-theater.md
T

30 KiB
Raw Blame History

故事剧场化实现计划(三幕演出 + 动作卡全互动呈现)

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 双审查 ApprovedNIT 合并修复)

  • 删除残留死样式 .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/ 200dev server 已在跑)。浏览器打开 http://localhost:5173 走查:

  1. 进入任意关卡 → 开场演出自动播放(旁白朗读、字幕逐字淡入、角色滑入、道具飞入),点「跳过」立即进入
  2. 决策节点显示动作卡(图标/拼音/听读/四色边框),点击有按压动画
  3. 选择后场景晃动 0.6s 再弹点评(你的选择 + 好处/坏处)
  4. 走完分支到结算 → 通关显示彩带 + 庆祝标题;故意走错到未通关 → 鼓励语
  5. 拼音开关、声音开关、进度点正常
  • Step 5: 提交
git add README.md 技术设计.md
git commit -m "docs: 三幕剧场化设计(README 情境闯关行 + 技术设计 4.13 补充)"

计划外可选(本次不实施)

  • 36 关入口互动数据补全(种子补道具选项):触控互动覆盖面由后台配置扩展,非本计划范围
  • genasset 角色表情帧/动作帧素材(阶段 C):依赖 Task 10 素材管线完成后另行计划