Files
36Wisdom/docs/superpowers/plans/2026-08-13-story-theater-v2.md
T
2026-08-14 09:13:23 +08:00

60 KiB
Raw Blame History

互动剧情演绎 v2 Implementation Plan

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: 每个决策节点从「一段描述 + 答题」升级为「剧本化台词流剧情演绎」,决策与反馈嵌入剧情流,内容由 oMLX 批量生成草稿 + 人工精修 + 导入回填。

Architecture: 后端零接口改动——仅 scene_node 新增 script 列(JSON 台词流,{speaker,text,pinyin,emotion} 数组),经 NodeVO.Script 透出;前端新增 StoryPlayer 逐句演绎组件(气泡 + 自动朗读 + 逐字高亮 + 表情切换),play.vue 在 script 非空时走剧情流(StoryPlayer → ActionCard/触控 → 角色回应气泡,无弹窗),script 为空完整回退现有 v1 呈现;genasset 新增 --only=scripts(生成草稿到 workspace/scripts/{level_id}/{node_id}.json)与 --import-scripts(校验 + common.AnnotatePinyin 注音 + 回填)。

Tech Stack: GoFrame + SQLite(后端)、uni-app Vue 3(前端)、oMLX OpenAI 兼容接口(内容生成)、CSS keyframes(演出动画)。

规格: docs/superpowers/specs/2026-08-13-story-theater-design.md「v2 修订」章节


文件结构

文件 动作 职责
biz/model/entity/entity_scene_node.go Modify SceneNode 加 Script 字段
biz/dao/dao_scene_node.go Modify CREATE TABLE 加 script TEXT + EnsureColumn 迁移
biz/model/dto/level.go Modify NodeVO 加 Script string \json:"script"``
biz/service/level.go Modify buildNode 透出 Script
biz/controller/level.go Modify nodeVO 映射透出 Script
ui-src/src/utils/theater.js Modify parseScript(raw)JSON 解析 + 空值回退)
ui-src/src/components/RubyText.vue Modify highlight prop(逐字高亮)
ui-src/src/components/StoryPlayer.vue Create 台词流演绎器:气泡/朗读/高亮/表情/跳过
ui-src/src/pages/play/play.vue Modify 剧情流集成 + 回退保护
cmd/genasset/prompts.go Modify scriptSystem/scriptUser
cmd/genasset/scripts.go Create 剧本生成/草稿/校验/导入
cmd/genasset/scripts_test.go Create validateScript 单测
cmd/genasset/main.go Modify --only=scripts / --import-scripts 接线 + 清单
README.md / 技术设计.md Modify 剧情演绎功能与设计补充

Task 1: 后端 script 列 + 透出

Files:

  • Modify: biz/model/entity/entity_scene_node.go

  • Modify: biz/dao/dao_scene_node.go:17-37

  • Modify: biz/model/dto/level.go:31-44

  • Modify: biz/service/level.go:213-242

  • Step 1: entity 加 Script 字段

entity_scene_node.goConfig 字段后加:

	Config          string `json:"config" orm:"config"`
	Script          string `json:"script" orm:"script"`
  • Step 2: dao 建表 + 迁移

dao_scene_node.go 的 CREATE TABLE 中 config TEXT, 后加一行,并在 Init 末尾 EnsureColumn

  config        TEXT,
  script        TEXT,
	common.EnsureColumn(ctx, consts.TableSceneNode, "content_pinyin", "TEXT")
	common.EnsureColumn(ctx, consts.TableSceneNode, "script", "TEXT")
  • Step 3: dto NodeVO 加 Script

dto/level.go 的 NodeVO 中 Config 后加:

	Config          string     `json:"config"`
	Script          string     `json:"script"`
  • Step 4: service buildNode 透出

service/level.go buildNode 的 Node 结构体加字段:

	Config          string
	Script          string

并在 buildNodeConfig: nodeRec["config"].String(), 后加:

		Script:          nodeRec["script"].String(),
  • Step 5: controller nodeVO 透出

controller/level.go 的 nodeVO 中 Config: n.Config, 后加:

		Script:          n.Script,
  • Step 6: 编译 + 既有单测验证

Run: go build ./... && go test ./biz/service/ ./common/ Expected: 全部 PASSlevel_play / pinyin 单测不受影响)

  • Step 7: Commit
git add biz/model/entity/entity_scene_node.go biz/dao/dao_scene_node.go biz/model/dto/level.go biz/service/level.go biz/controller/level.go
git commit -m "feat: scene_node 新增 script 剧本列并透出 NodeVOv2 剧情演绎数据层)"

Task 2: theater.js parseScript + RubyText highlight prop

Files:

  • Modify: ui-src/src/utils/theater.js

  • Modify: ui-src/src/components/RubyText.vue

  • Step 1: theater.js 加 parseScript

ui-src/src/utils/theater.js 末尾追加:

// parseScript 解析节点剧本(JSON 台词流),非法/为空返回 null(调用方回退 v1 呈现)
export function parseScript(raw) {
  if (!raw) return null
  try {
    const arr = JSON.parse(raw)
    return Array.isArray(arr) && arr.length > 0 ? arr : null
  } catch (e) {
    return null
  }
}
  • Step 2: RubyText 加 highlight prop

RubyText.vue 三处修改:

props 加:

    highlight: { type: Number, default: -1 }, // >=0 逐字高亮到该下标;-1 不启用

模板中 ruby-ch 加条件 classpairsi 即字下标):

      <text class="ruby-ch" :class="{ 'ruby-hl': highlight >= 0 && i < highlight, 'ruby-cur': i === highlight }">{{ ch.c }}</text>

样式加:

.ruby-hl { color: #ff6b35; font-weight: 700; }
.ruby-cur { color: #ff6b35; font-weight: 800; animation: cur-pop 0.3s ease; }
@keyframes cur-pop { 0% { transform: scale(1.35); } 100% { transform: scale(1.15); } }
  • Step 3: 编译检查

Run: cd ui-src && npx vite build --mode h5 2>&1 | tail -3 Expected: 构建成功无报错(dev server 已开时 npx vite build 也可省略,仅做语法验证)

审查记录(fd67912 双审查 APPROVE):

  • NIT1(已修):.dark .ruby-hl/.ruby-cur 特异性低于 .dark .ruby-ch,深色模式高亮色被覆盖,补 .dark 变体
  • NIT2(记录待验证):cur-pop 动画的 transform 作用于小程序 <text> 不可靠,真机验证;无效则挪到外层 view
  • NIT3(不修):parseScript 对 [null] 等畸形元素未校验,后端导入校验保证 text 合法,调用方兜底
  • Step 4: Commit
git add ui-src/src/utils/theater.js ui-src/src/components/RubyText.vue
git commit -m "feat: 前端剧本解析 parseScript + RubyText 逐字高亮 highlight prop"

Task 3: StoryPlayer 台词演绎器

Files:

  • Create: ui-src/src/components/StoryPlayer.vue

  • Modify: ui-src/src/utils/speech.jsspeak 加 onEnd 回调)

  • Step 1: speech.js 加 onEnd 回调

speech.js 修改(逐句朗读需要「读完了再进下一句」的事件,仅定时器推进会在 TTS 慢时切掉上句音频):

function playFile(url, onEnd) {
  stopSpeak()
  if (!innerAudio) innerAudio = uni.createInnerAudioContext()
  innerAudio.stop()
  innerAudio.src = url
  if (onEnd) innerAudio.onEnded = () => onEnd()
  innerAudio.play()
}

function speakBrowser(text, onEnd) {
  stopSpeak()
  // H5 专用:浏览器原生语音,零成本朗读(小程序端后续接 TTS 文件)
  if (typeof window === 'undefined' || !window.speechSynthesis) {
    if (onEnd) onEnd()
    return
  }
  const u = new SpeechSynthesisUtterance(text)
  u.lang = 'zh-CN'
  u.rate = 0.9
  u.onend = u.onerror = () => { uttering = false; if (onEnd) onEnd() }
  window.speechSynthesis.speak(u)
  uttering = true
}

// speak 朗读一段文本:有音频文件优先播文件,否则浏览器语音降级;onEnd 在读完时回调(无 TTS 能力时立即回调)
export function speak(text, fileUrl, onEnd) {
  if (!text) return
  if (fileUrl) {
    playFile(fileUrl, onEnd)
    return
  }
  speakBrowser(text, onEnd)
}
  • Step 2: 创建组件

完整创建 ui-src/src/components/StoryPlayer.vue

<template>
  <view class="story">
    <view class="story-skip" @click="finish">跳过 </view>

    <view v-if="character" class="story-char">
      <image v-if="character.image" class="story-char-img" :src="character.image" mode="aspectFit" />
      <view v-else class="story-char-avatar" :style="{ background: charColor }">{{ character.name.slice(0, 1) }}</view>
      <view class="story-mood" :class="{ pop: moodTick }">{{ moodEmoji }}</view>
    </view>

    <view class="story-lines">
      <view v-for="(line, i) in script" :key="i" class="story-line" :class="{ active: i === lineIndex, done: i < lineIndex }">
        <view v-if="line.speaker === '旁白'" class="bubble nar-bubble">
          <view class="bubble-name">📖 旁白</view>
          <RubyText :text="line.text" :pinyin="line.pinyin || ''" :show="showPinyin" :highlight="i === lineIndex ? charIndex : -1" />
        </view>
        <view v-else class="bubble char-bubble">
          <view class="bubble-name">{{ line.speaker }}</view>
          <RubyText :text="line.text" :pinyin="line.pinyin || ''" :show="showPinyin" :highlight="i === lineIndex ? charIndex : -1" />
        </view>
      </view>
    </view>
  </view>
</template>

<script>
import RubyText from './RubyText.vue'
import { personVisual } from '../utils/visual.js'
import { speak, stopSpeak } from '../utils/speech.js'

const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' }
const PER_CHAR = 260 // 每字高亮推进毫秒(与 TTS 朗读大致同步)
const PAUSE_MS = 700 // 句间停顿
const MAX_LINE_MS = 20000 // 单句最长等待(TTS 无声兜底,防卡死)

export default {
  name: 'StoryPlayer',
  components: { RubyText },
  props: {
    script: { type: Array, default: () => [] }, // [{speaker, text, pinyin, emotion}]
    character: { type: Object, default: null }, // {name, image, id}
    showPinyin: { type: Boolean, default: true }
  },
  emits: ['done'],
  data() {
    return { lineIndex: 0, charIndex: 0, moodTick: false, timers: [], ended: false, lineDone: { hl: false, sp: false } }
  },
  computed: {
    moodEmoji() {
      const line = this.script[this.lineIndex]
      return EMOJI[(line && line.emotion) || 'normal'] || EMOJI.normal
    },
    charColor() {
      return personVisual(this.character && this.character.id ? this.character.id : 0).color
    }
  },
  mounted() {
    this.playLine(0)
  },
  beforeUnmount() {
    this.clearTimers()
    stopSpeak()
  },
  methods: {
    clearTimers() {
      this.timers.forEach(t => { clearTimeout(t); clearInterval(t) })
      this.timers = []
    },
    after(ms, fn) {
      this.timers.push(setTimeout(fn, ms))
    },
    playLine(i) {
      const line = this.script[i]
      if (!line) {
        this.$emit('done')
        return
      }
      this.lineIndex = i
      this.charIndex = 0
      this.ended = false
      this.lineDone = { hl: false, sp: false }
      this.moodTick = true
      this.after(350, () => { this.moodTick = false })
      uni.pageScrollTo({ scrollTop: 99999, duration: 200 })
      const total = Array.from(line.text).length
      // 逐字高亮推进(纯视觉节奏)
      const step = setInterval(() => {
        this.charIndex++
        if (this.charIndex >= total) {
          clearInterval(step)
          this.lineDone.hl = true
          this.tryNext(i)
        }
      }, PER_CHAR)
      this.timers.push(step)
      // 朗读读完才进下一句(TTS 慢时高亮等朗读);onEnd 与高亮都完成 → 停顿后下一句
      speak(line.text, '', () => {
        this.lineDone.sp = true
        this.tryNext(i)
      })
      // 兜底:TTS 无声/超时(20s)强制推进防卡死
      this.after(MAX_LINE_MS, () => this.forceNext(i))
    },
    tryNext(i) {
      if (this.ended || this.lineIndex !== i || !this.lineDone.hl || !this.lineDone.sp) return
      this.ended = true
      this.clearTimers()
      this.after(PAUSE_MS, () => { this.ended = false; this.playLine(i + 1) })
    },
    forceNext(i) {
      if (this.ended || this.lineIndex !== i) return
      this.ended = true
      this.clearTimers()
      stopSpeak()
      this.after(PAUSE_MS, () => { this.ended = false; this.playLine(i + 1) })
    },
    finish() {
      this.ended = true
      this.clearTimers()
      stopSpeak()
      this.$emit('done')
    }
  }
}
</script>

<style scoped>
.story { position: relative; padding: 24rpx 8rpx 32rpx; }
.story-skip { position: fixed; top: calc(140rpx + env(safe-area-inset-top)); right: 24rpx; font-size: 26rpx; color: #a08c74; background: #fff; border-radius: 30rpx; padding: 8rpx 24rpx; z-index: 20; }
.story-char { position: fixed; right: 24rpx; bottom: 48rpx; z-index: 3; }
.story-char-img { width: 140rpx; height: 140rpx; border-radius: 50%; background: #f8f2e8; box-shadow: 0 8rpx 24rpx rgba(91, 70, 54, 0.15); }
.story-char-avatar { width: 140rpx; height: 140rpx; border-radius: 50%; color: #fff; font-size: 64rpx; font-weight: 800; display: flex; align-items: center; justify-content: center; }
.story-mood { position: absolute; right: -10rpx; bottom: -10rpx; font-size: 44rpx; background: #fff; border-radius: 50%; width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; box-shadow: 0 2rpx 8rpx rgba(91, 70, 54, 0.2); }
.story-mood.pop { animation: mood-pop 0.35s ease; }
@keyframes mood-pop { 0% { transform: scale(0.3) rotate(-20deg); } 100% { transform: scale(1) rotate(0); } }
.story-lines { display: flex; flex-direction: column; gap: 24rpx; padding-bottom: 24rpx; }
.story-line { opacity: 0; transition: opacity 0.4s; }
.story-line.active { opacity: 1; }
.story-line.done { opacity: 0.55; }
.bubble { border-radius: 24rpx; padding: 24rpx 28rpx; box-shadow: 0 4rpx 16rpx rgba(91, 70, 54, 0.08); }
.bubble-name { font-size: 24rpx; font-weight: 700; color: #ff6b35; margin-bottom: 8rpx; }
.nar-bubble { background: #fff3d6; margin-left: 40rpx; margin-right: 40rpx; }
.char-bubble { background: #ffffff; margin-left: 32rpx; margin-right: 8rpx; }
.story-line.active .bubble { animation: bubble-in 0.35s ease; }
@keyframes bubble-in { 0% { opacity: 0; transform: translateY(24rpx) scale(0.96); } 100% { opacity: 1; transform: translateY(0) scale(1); } }
</style>
  • Step 3: 语法验证

Run: cd ui-src && npx vite build --mode h5 2>&1 | tail -3 Expected: 构建成功(组件未被引用,仅语法检查)

  • Step 4: Commit
git add ui-src/src/components/StoryPlayer.vue ui-src/src/utils/speech.js
git commit -m "feat: StoryPlayer 台词流演绎器(气泡+朗读+逐字高亮+表情切换+跳过)"

审查记录(58b3e57 双审查:第一 APPROVE,第二 2 SHOULD-FIX,合并修复):

  • SHOULD-FIX1(已修):speak 无完成事件,TTS 慢时逐字定时器推进会切掉上句音频 → speech.js speak 加 onEnd 回调,StoryPlayer「高亮完成 + 朗读 onEnd 都满足 → 停顿 → 下一句」,MAX_LINE_MS=20s 超时 forceNext 兜底防 TTS 无声卡死;nextLine 加 lineIndex/ended 竞态保护
  • SHOULD-FIX2(已修):pageScrollTo 后跳过按钮滚出视口(4-8 岁孩子找不回)→ .story-skip 改 fixedtop 140rpx 避开 topbar
  • NIT(已合并修):moodTick 300→350ms 复位(动画不截断);speaking 死状态删除
  • NIT(记录待 Task 6 走查):.story-char fixed 角色立牌可能与选项区重叠,走查确认,必要时降 absolutecharacter.name 非空由数据保证

Task 4: play.vue 剧情流集成

Files:

  • Modify: ui-src/src/pages/play/play.vue

  • Modify: ui-src/src/components/ActionCard.vue(仅当走查发现需要时)

  • Step 1: import 与 data/computed

play.vue script 区:

import 行加(SceneTheater 行后):

import StoryPlayer from '../../components/StoryPlayer.vue'

pickInteraction import 行改为同时引入 parseScript

import { pickInteraction, entryProps, entryCharacter, parseScript } from '../../utils/theater.js'

components 注册加 StoryPlayer

data() 加:

      showOptions: false,
      reply: null,

computed 加:

    storyScript() {
      return this.curNode ? parseScript(this.curNode.script) : null
    },
    isStoryNode() {
      return !!this.storyScript
    }
  • Step 2: 模板加剧情流分支

<!-- 节点:互动分发 --> 处,把现有 <view v-if="curNode" class="node card">…</view> 整体改为:

      <!-- 节点:剧情流(script 非空)或 v1 卡片(回退) -->
      <view v-if="curNode && isStoryNode" class="node story-node">
        <StoryPlayer :key="curNode.node_id" :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>

(原节点卡片内触控组件分发、ActionCard、soon 分支原样保留在 v-else 分支中。)

  • Step 3: submit/continuePlay 剧情流分支

submit()if (data.next) { … } 分支整体替换为:

        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 后出反馈(剧情流=回应气泡,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 = ''
            if (this.isStoryNode) {
              this.reply = fb
              this.showOptions = false // 提交后隐藏选项区,防重复点击
            } else {
              this.feedback = fb
            }
            this.choosing = false
          }, 600)

continuePlay() 替换为:

    continuePlay() {
      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 = '🤔'
    }
  • Step 4: 样式

style 区追加:

.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; }

.fb-line/.fb-prop/.fb-continue 等样式 v1 已有,直接复用。)

  • Step 5: 端到端验证(手工造数据)

给第 1 计入口节点手工填一条剧本(后端与 H5 dev server 需在运行;命令在项目根目录执行):

sqlite3 data/36wisdom.db "UPDATE scene_node SET script='[{\"speaker\":\"旁白\",\"text\":\"夜深了,城墙高高的。\",\"emotion\":\"normal\"},{\"speaker\":\"小军师\",\"text\":\"我们怎么才能悄悄进城呢?\",\"emotion\":\"think\"}]' WHERE id=(SELECT id FROM scene_node WHERE level_id=(SELECT id FROM level WHERE strategy_id=1 AND status=1 LIMIT 1) AND is_entry=1);"

H5 走查 http://localhost:5173/#/pages/play/play?level_id=<第1计关卡>level_id 查:sqlite3 data/36wisdom.db "SELECT id, title FROM level WHERE strategy_id=1 LIMIT 1;"):

  1. 剧情流显示两句气泡(旁白居中 + 角色提问),逐字高亮推进、自动朗读、mood 徽章 🤔
  2. 播完(或点跳过)ActionCard 出现在剧情流下方
  3. 选择后 0.6s 回应气泡融入剧情流(✓/✗ 两行 + 继续按钮),无遮罩弹窗
  4. 点「继续」进入下一节点剧情流
  5. 其他关卡(script 为空)仍是 v1 卡片呈现(回退保护)

验证后还原测试数据:sqlite3 data/36wisdom.db "UPDATE scene_node SET script='' WHERE script IS NOT NULL AND script != '';"(仅当本节点 script 原为空;本步只改了这一个节点)

  • Step 6: Commit
git add ui-src/src/pages/play/play.vue
git commit -m "feat: play.vue 剧情流集成——StoryPlayer + 选项嵌入 + 角色回应气泡(script 空回退 v1"

审查记录(5e19174 双审查一致):

  • BLOCKER(已修):连续剧情节点卡死——StoryPlayer 无 script watch、play.vue 无 :key,节点切换时 v-if 恒真组件不重建不重播,done 永不触发 → 选项区永不出现。修复:<StoryPlayer :key="curNode.node_id" …/> 强制按节点重建重播
  • SHOULD-FIX(已修):reply 出现后 showOptions 仍 trueActionCard 仍可点击 → 可重复 submit。修复:submit 成功分支置 showOptions = false

Task 5: genasset 剧本生成管线

Files:

  • Modify: cmd/genasset/prompts.go

  • Create: cmd/genasset/scripts.go

  • Create: cmd/genasset/scripts_test.go

  • Modify: cmd/genasset/main.go

  • Step 1: prompts.go 加剧本 prompt

prompts.go 末尾追加:

const scriptSystem = `你是儿童故事编剧。把下面的闯关情境改写成一场 3-6 句的微型剧情台词流,语言儿童化、口语化、温和,每句 8-30 字(含标点)。
规则:
1. 至少 1 句旁白交代情境(speaker 为「旁白」),其余为角色台词
2. speaker 只能是「旁白」或角色名
3. 决策节点:最后一句必须是角色向孩子提问,以「?」结尾
4. 终局节点:最后一句是角色的总结或鼓励
5. 每句可标注情绪 emotionnormal/happy/sad/think/surprise,可不标)
只输出 JSON{"script": [{"speaker": "旁白", "text": "……"}]}`

const scriptUser = `情境:%s

角色:%s
%s`
  • Step 2: 写失败单测

创建 cmd/genasset/scripts_test.go

package main

import "testing"

func TestValidateScript_DecisionOK(t *testing.T) {
	lines := []scriptLine{
		{Speaker: "旁白", Text: "夜深了,城里静悄悄的。"},
		{Speaker: "小军师", Text: "城门关得紧紧的。"},
		{Speaker: "小军师", Text: "我们怎么才能悄悄进城呢?", Emotion: "think"},
	}
	if err := validateScript(lines, true, "小军师"); err != nil {
		t.Fatalf("合法决策剧本应通过: %v", err)
	}
}

func TestValidateScript_FinalOK(t *testing.T) {
	lines := []scriptLine{
		{Speaker: "旁白", Text: "天亮了,城门缓缓打开。"},
		{Speaker: "小军师", Text: "我们一起进城啦!", Emotion: "happy"},
		{Speaker: "小军师", Text: "大家平平安安,真好!"},
	}
	if err := validateScript(lines, false, "小军师"); err != nil {
		t.Fatalf("合法终局剧本应通过: %v", err)
	}
}

func TestValidateScript_TooFewLines(t *testing.T) {
	lines := []scriptLine{{Speaker: "旁白", Text: "夜深了,城里静悄悄的。"}}
	if err := validateScript(lines, true, "小军师"); err == nil {
		t.Fatal("不足 3 句应报错")
	}
}

func TestValidateScript_MissingQuestion(t *testing.T) {
	lines := []scriptLine{
		{Speaker: "旁白", Text: "夜深了,城里静悄悄的。"},
		{Speaker: "小军师", Text: "城门关得紧紧的。"},
		{Speaker: "小军师", Text: "我们悄悄等天亮吧。"},
	}
	if err := validateScript(lines, true, "小军师"); err == nil {
		t.Fatal("决策节点末句未提问应报错")
	}
}

func TestValidateScript_BadSpeaker(t *testing.T) {
	lines := []scriptLine{
		{Speaker: "旁白", Text: "夜深了,城里静悄悄的。"},
		{Speaker: "路人甲", Text: "城门关得紧紧的。"},
		{Speaker: "小军师", Text: "我们怎么才能悄悄进城呢?"},
	}
	if err := validateScript(lines, true, "小军师"); err == nil {
		t.Fatal("非法 speaker 应报错")
	}
}

func TestValidateScript_BadLength(t *testing.T) {
	lines := []scriptLine{
		{Speaker: "旁白", Text: "夜深了。"},
		{Speaker: "小军师", Text: "城门关得紧紧的。"},
		{Speaker: "小军师", Text: "我们怎么才能悄悄进城呢?"},
	}
	if err := validateScript(lines, true, "小军师"); err == nil {
		t.Fatal("台词不足 8 字应报错")
	}
}
  • Step 3: 运行确认失败

Run: go test ./cmd/genasset/ -run TestValidateScript -v Expected: 编译失败(validateScript / scriptLine 未定义)

  • Step 4: 实现 scripts.go

创建 cmd/genasset/scripts.go

package main

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"unicode/utf8"

	"github.com/gogf/gf/v2/database/gdb"
	"github.com/gogf/gf/v2/frame/g"
	"github.com/gogf/gf/v2/os/glog"

	"36wisdom/biz/consts"
	"36wisdom/common"
)

// scriptLine 台词流单句(导入时补 pinyin)
type scriptLine struct {
	Speaker string `json:"speaker"`
	Text    string `json:"text"`
	Emotion string `json:"emotion"`
}

// scriptDraft 草稿文件结构(人工精修对象;pinyin 不入草稿,导入时统一生成)
type scriptDraft struct {
	LevelID   int64        `json:"level_id"`
	NodeID    int64        `json:"node_id"`
	Character string       `json:"character"`
	Decision  bool         `json:"decision"`
	Content   string       `json:"content"`
	Options   []string     `json:"options,omitempty"`
	Script    []scriptLine `json:"script"`
}

func scriptDir(levelID int64) string {
	return filepath.Join(assetDir, "scripts", fmt.Sprintf("%d", levelID))
}

func scriptPath(levelID, nodeID int64) string {
	return filepath.Join(scriptDir(levelID), fmt.Sprintf("%d.json", nodeID))
}

// pendingScriptNodes 待生成/待导入剧本的节点(script 为空)
func pendingScriptNodes(fl genFlags) []gdb.Record {
	m := g.DB().Model(consts.TableSceneNode).Where("script IS NULL OR script = ''")
	if fl.strategy > 0 {
		levelIDs, _ := strategyScope(fl)
		m = m.WhereIn("level_id", levelIDs)
	}
	rows, err := m.All(ctx)
	if err != nil {
		glog.Fatal(ctx, err)
	}
	return rows
}

// ---------- 生成 ----------

func genScripts(fl genFlags) {
	rows := pendingScriptNodes(fl)
	if len(rows) == 0 {
		fmt.Println("[scripts] 无待生成节点")
		return
	}
	charNames := scriptCharNames(rows)
	optsByNode := scriptOptions(rows)
	sem := make(chan struct{}, 2)
	var wg sync.WaitGroup
	for _, r := range rows {
		if !fl.force && fileExists(scriptPath(r["level_id"].Int64(), r["id"].Int64())) {
			continue // 幂等:草稿已存在跳过(--force 覆盖;防重跑覆盖人工精修草稿)
		}
		wg.Add(1)
		sem <- struct{}{}
		go func(r gdb.Record) {
			defer wg.Done()
			defer func() { <-sem }()
			if err := genNodeScript(r, charNames, optsByNode); err != nil {
				glog.Errorf(ctx, "[scripts] node %d 失败: %v", r["id"].Int64(), err)
			}
		}(r)
	}
	wg.Wait()
}

func scriptCharNames(rows []gdb.Record) map[int64]string {
	charIDs := map[int64]bool{}
	for _, r := range rows {
		if cid := r["character_id"].Int64(); cid > 0 {
			charIDs[cid] = true
		}
	}
	names := map[int64]string{}
	if len(charIDs) == 0 {
		return names
	}
	ids := make([]int64, 0, len(charIDs))
	for id := range charIDs {
		ids = append(ids, id)
	}
	recs, err := g.DB().Model(consts.TableElement).WhereIn("id", ids).All(ctx)
	if err != nil {
		glog.Fatal(ctx, err)
	}
	for _, r := range recs {
		names[r["id"].Int64()] = r["name"].String()
	}
	return names
}

func scriptOptions(rows []gdb.Record) map[int64][]string {
	var nodeIDs []int64
	for _, r := range rows {
		nodeIDs = append(nodeIDs, r["id"].Int64())
	}
	byNode := map[int64][]string{}
	if len(nodeIDs) == 0 {
		return byNode
	}
	opts, err := g.DB().Model(consts.TableNodeOption).WhereIn("node_id", nodeIDs).All(ctx)
	if err != nil {
		glog.Fatal(ctx, err)
	}
	for _, o := range opts {
		nid := o["node_id"].Int64()
		byNode[nid] = append(byNode[nid], o["text"].String())
	}
	return byNode
}

func genNodeScript(node gdb.Record, charNames map[int64]string, optsByNode map[int64][]string) error {
	nid := node["id"].Int64()
	charName := charNames[node["character_id"].Int64()]
	if charName == "" {
		charName = "小军师"
	}
	decision := node["result_type"].Int() == 0
	var tail string
	if decision {
		opts := optsByNode[nid]
		lines := make([]string, 0, len(opts))
		for i, o := range opts {
			lines = append(lines, fmt.Sprintf("%d. %s", i+1, o))
		}
		tail = "选项:\n" + strings.Join(lines, "\n")
	} else {
		tail = "这是结局节点:由角色说出结局与鼓励,不做提问。"
	}
	user := fmt.Sprintf(scriptUser, node["content"].String(), charName, tail)
	out, err := genClient.chat(ctx, scriptSystem, user)
	if err != nil {
		return err
	}
	var resp struct {
		Script []scriptLine `json:"script"`
	}
	if err := json.Unmarshal([]byte(out), &resp); err != nil {
		return fmt.Errorf("剧本 JSON 解析失败: %v(输出: %s", err, truncate(out, 200))
	}
	if err := validateScript(resp.Script, decision, charName); err != nil {
		return fmt.Errorf("剧本校验失败: %v(输出: %s", err, truncate(out, 300))
	}
	draft := scriptDraft{
		LevelID: node["level_id"].Int64(), NodeID: nid, Character: charName,
		Decision: decision, Content: node["content"].String(), Script: resp.Script,
	}
	if decision {
		draft.Options = optsByNode[nid]
	}
	b, _ := json.MarshalIndent(draft, "", "  ")
	if err := os.MkdirAll(scriptDir(node["level_id"].Int64()), 0o755); err != nil {
		return err
	}
	if err := os.WriteFile(scriptPath(node["level_id"].Int64(), nid), b, 0o644); err != nil {
		return err
	}
	fmt.Printf("[scripts] ok node %d%d 句)\n", nid, len(resp.Script))
	return nil
}

// validateScript 校验台词流结构(生成与导入共用)
func validateScript(lines []scriptLine, decision bool, charName string) error {
	if len(lines) < 3 || len(lines) > 6 {
		return fmt.Errorf("台词应为 3-6 句,实际 %d 句", len(lines))
	}
	hasNar := false
	last := lines[len(lines)-1]
	for _, l := range lines {
		if l.Speaker == "旁白" {
			hasNar = true
		} else if l.Speaker != charName {
			return fmt.Errorf("speaker %q 非法(应为 旁白 或 %s", l.Speaker, charName)
		}
		if n := utf8.RuneCountInString(l.Text); n < 8 || n > 30 {
			return fmt.Errorf("台词应 8-30 字,实际 %d 字:%s", n, l.Text)
		}
		if l.Emotion != "" && !validEmotion(l.Emotion) {
			return fmt.Errorf("emotion %q 非法", l.Emotion)
		}
	}
	if !hasNar {
		return fmt.Errorf("至少 1 句旁白")
	}
	if decision {
		if last.Speaker != charName {
			return fmt.Errorf("决策节点末句必须是 %s 提问", charName)
		}
		if !strings.HasSuffix(last.Text, "") && !strings.HasSuffix(last.Text, "?") {
			return fmt.Errorf("决策节点末句必须以问号结尾:%s", last.Text)
		}
	} else if last.Speaker == "旁白" {
		return fmt.Errorf("终局节点末句应为角色总结")
	}
	return nil
}

func validEmotion(e string) bool {
	switch e {
	case "normal", "happy", "sad", "think", "surprise":
		return true
	}
	return false
}

// ---------- 导入 ----------

func importScripts(fl genFlags) {
	rows := pendingScriptNodes(fl)
	imported := 0
	for _, r := range rows {
		p := scriptPath(r["level_id"].Int64(), r["id"].Int64())
		b, err := os.ReadFile(p)
		if err != nil {
			continue // 无草稿跳过
		}
		var d scriptDraft
		if err := json.Unmarshal(b, &d); err != nil {
			glog.Errorf(ctx, "[scripts] 草稿解析失败 %s: %v", p, err)
			continue
		}
		if d.NodeID != r["id"].Int64() {
			glog.Errorf(ctx, "[scripts] 草稿 node 不匹配 %s", p)
			continue
		}
		if err := validateScript(d.Script, d.Decision, d.Character); err != nil {
			glog.Errorf(ctx, "[scripts] 草稿校验失败 %s: %v", p, err)
			continue
		}
		lines := make([]map[string]string, 0, len(d.Script))
		for _, l := range d.Script {
			lines = append(lines, map[string]string{
				"speaker": l.Speaker, "text": l.Text,
				"pinyin": common.AnnotatePinyin(l.Text), "emotion": l.Emotion,
			})
		}
		b2, _ := json.Marshal(lines)
		if _, err := g.DB().Model(consts.TableSceneNode).Ctx(ctx).Data(g.Map{"script": string(b2)}).Where("id", d.NodeID).Update(); err != nil {
			glog.Errorf(ctx, "[scripts] 导入失败 node %d: %v", d.NodeID, err)
			continue
		}
		fmt.Printf("[scripts] 导入 node %d\n", d.NodeID)
		imported++
	}
	fmt.Printf("[scripts] 导入完成:%d 个节点\n", imported)
}
  • Step 5: main.go 接线

main.go 三处修改:

genFlags struct 加字段:

	importScripts bool

parseFlags 加:

	flag.BoolVar(&fl.importScripts, "import-scripts", false, "从草稿导入剧本到数据库(--strategy 限定计策)")

main() 的分发逻辑替换为:

	if fl.listOnly {
		printInventory(fl)
		return
	}
	if fl.importScripts {
		importScripts(fl)
		return
	}
	if fl.only == "scripts" {
		genScripts(fl)
		return
	}
	if fl.only == "" || fl.only == "comments" {
		genComments(fl)
	}
	if fl.only == "" || fl.only == "visuals" {
		genVisuals(fl)
	}

printInventory 中 comments 行后加:

	fmt.Printf("待生成剧本节点:%d\n", len(pendingScriptNodes(fl)))

文件头注释 // --only=comments 行后加两行:

//	go run ./cmd/genasset --only=scripts            # 生成剧本草稿(workspace/scripts/
//	go run ./cmd/genasset --import-scripts          # 草稿导入回填 scene_node.script
  • Step 6: 单测 + 编译

Run: go test ./cmd/genasset/ -v && go build ./... Expected: TestValidateScript_* 全部 PASS6 个);go build ./... 成功

  • Step 7: 清单验证

Run: go run ./cmd/genasset --list 2>&1 | head -5 Expected: 输出含「待生成剧本节点:N」(N 为 DB 中 script 为空节点数,>0)与既有 comments/visuals 清单

  • Step 8: Commit
git add cmd/genasset/prompts.go cmd/genasset/scripts.go cmd/genasset/scripts_test.go cmd/genasset/main.go
git commit -m "feat: genasset 剧本管线——--only=scripts 草稿生成 + --import-scripts 校验注音回填"

审查记录(c40d833 双审查:第一 APPROVE,第二 1 SHOULD-FIX,合并修复):

  • SHOULD-FIX(已修):genScripts 无草稿幂等检查,重跑覆盖人工精修草稿 → 循环内加「草稿存在且非 --force 则跳过」(fileExists(scriptPath) 检查),与 main.go 幂等文档契约一致
  • 计划偏差(implementer 修正合理):TestValidateScript_FinalOK 原数据 2 句违反 3-6 句校验,改 3 句
  • NIT(不修,记录):导入校验信任草稿自身 Decision/Character 字段(人工精修对象);互动入口节点(2/3/5/7/8)祈使指令类 content 强制末句提问可能套路化——Task 6 精修时留意;pinyin 字段为字符串的链路无单测(与 content_pinyin 同模式,风险低)

Task 6: 第 1 计试点生成 + 精修 + 端到端验证

Files:

  • 运行产物:workspace/scripts/1/*.json(人工精修对象)

前置:oMLX 运行中(127.0.0.1:18080);后端运行中。

  • Step 1: 生成第 1 计剧本草稿

Run: go run ./cmd/genasset --only=scripts --strategy=1 2>&1 | tail -8 Expected: [scripts] ok node N3-6 句) ×第 1 计节点数(约 5-8 个);本地模型每节点 2-4 分钟,总耗时约 10-30 分钟

  • Step 2: 检查失败与草稿质量

Run: ls workspace/scripts/1/ && cat workspace/scripts/1/*.json | head -60 检查:

  1. 每个节点有草稿文件(校验失败的节点会有日志,可 --force 重跑——先修 prompt 或手工改草稿)
  2. 台词符合儿童口语、末句提问(决策节点)/ 总结(终局节点)、emotion 合理
  • Step 3: 人工精修 1-2 处

编辑 workspace/scripts/1/*.json:把至少 1 句改得更口语(例如把「我们须设法潜入」改为「我们想办法偷偷进去吧」),确认 JSON 仍合法(node -e "JSON.parse(require('fs').readFileSync('<文件>','utf8')); console.log('ok')" 或编辑器格式化)。

  • Step 4: 导入第 1 计

Run: go run ./cmd/genasset --import-scripts --strategy=1 Expected: [scripts] 导入 node N × 节点数;导入完成:N 个节点

验证回填:

sqlite3 data/36wisdom.db "SELECT id, substr(script, 1, 80) FROM scene_node WHERE script != '' AND level_id IN (SELECT id FROM level WHERE strategy_id=1);"

Expected: 每行 script 为 JSON 开头 [{"speaker": 且含 pinyin 字段

  • Step 5: H5 端到端走查

走查 http://localhost:5173/#/pages/play/play?level_id=<第1计首个关卡 id>id 查:sqlite3 data/36wisdom.db "SELECT id, title FROM level WHERE strategy_id=1 ORDER BY sort_order LIMIT 3;"):

  1. 开场 SceneTheater 不变,点击「开始」进入剧情流
  2. 每句:气泡 + 自动朗读 + 逐字高亮(含拼音)+ 角色 mood 表情切换;播完提问句后 ActionCard 出现
  3. 选择 → 分支演出(晃动/音效)→ 回应气泡 ✓/✗ 融入剧情流 → 继续 → 下一节点继续演绎
  4. 走到终局节点:结局台词演绎 → 结算页(既有 confetti)
  5. 连走 3 关(第 1 计 3 个关卡)确认无 console 报错
  6. 走一关 script 为空的关卡(如第 2 计)确认 v1 回退正常

发现前端 bug:走「先修计划文件 → 派 fixer → 验证 → 提交」流程(既有双审查流)。

  • Step 6: Commit
git add workspace/scripts/
git commit -m "feat: 第 1 计剧本试点生成 + 人工精修 + 导入(剧情演绎端到端跑通)"

Task 7: 全量剧本生成 + 文档

Files:

  • Modify: README.md(功能清单「情境闯关」行)

  • Modify: 技术设计.md(4.13 剧场化设计补 v2 剧情演绎)

  • Step 1: 后台全量生成

Run: nohup go run ./cmd/genasset --only=scripts > workspace/scripts/gen.log 2>&1 &(或 run_in_background Expected: 日志逐节点输出 [scripts] ok node N;36 计约 200 节点、并发 2,约 2-4 小时完成(用 tail -f workspace/scripts/gen.log 抽查进度;期间可并行做 Step 2-3)

  • Step 2: README 更新

README.md 功能清单「情境闯关」行补充:关卡以剧情台词流演绎(旁白/角色对话 + 自动朗读 + 逐字高亮 + 表情切换),决策与点评嵌入剧情流;剧本由离线管线生成(oMLX 草稿 + 人工精修),见 genasset 用法。

  • Step 3: 技术设计.md 更新

技术设计.md 4.13 剧场化设计章节后补 v2 小节:

  • scene_node.script 列:JSON 台词流 {speaker, text, pinyin, emotion},3-6 句,决策节点末句角色提问、终局节点末句总结

  • 前端:StoryPlayer 演绎(气泡/朗读/逐字高亮/表情),play.vue 剧情流(script 空回退)

  • 管线:--only=scripts 草稿 → 人工精修 workspace/scripts/{level_id}/{node_id}.json--import-scripts(校验 + AnnotatePinyin)回填

  • 接口与判分零改动

  • Step 4: 全量导入

生成完成后:

Run: go run ./cmd/genasset --import-scripts 2>&1 | tail -3 Expected: 导入完成:N 个节点(N ≈ 全量节点数;校验失败的草稿先人工修后重跑导入)

  • Step 5: 抽查走查

第 1、18、36 计各走一关剧情演绎(H5),确认无报错、台词质量合格。

  • Step 6: Commit
git add README.md 技术设计.md workspace/scripts/
git commit -m "docs: 剧情演绎文档补充 + 全量剧本生成导入"

Task 8: 后端 FinalSettle.final_nodev2.1 终局演绎数据层)

v2.1:choose 到终局时,结局台词目前从未播放(前端直接跳结算页)。本任务让终局节点(含 script)随 FinalSettle 一并返回,前端 Task 9 先演绎再跳结算。

Files:

  • Modify: biz/service/level_play.go:32-43FinalSettle struct

  • Modify: biz/service/level_play.go:149-166Choose 终局分支)

  • Modify: biz/model/dto/level_play.go:18-28

  • Modify: biz/controller/level_play.go:25-37

  • Step 1: service FinalSettle 加 FinalNode

biz/service/level_play.go FinalSettle struct 末尾加:

	FinalNode *Node // 终局节点(含 script,v2.1 前端演绎结局台词后跳结算)
  • Step 2: Choose 终局分支返回终局 Node

biz/service/level_play.go Choose 终局分支改为先 nodeOf 再结算(内容表只读查询失败则未结算,无副作用;与决策分支同顺序):

	// v2.1:终局节点剧本先于结算构建(只读查询失败则未结算,无副作用)
	finalNode, err := nodeOf(ctx, levelRec, nextRec)
	if err != nil {
		return nil, nil, err
	}
	// 终局:路径流水在锁内结算后记录,避免状态读到本次到达导致重复结算误判
	settle, err := common.WithLock(ctx, fmt.Sprintf("child:%d:level:%d", childId, levelId), 10*time.Second, 3, 200*time.Millisecond, func() (*FinalSettle, error) {
		return s.settle(ctx, child, levelRec, nextRec, nodeId, optionId)
	})
	if err != nil {
		return nil, nil, err
	}
	settle.FinalNode = finalNode
	return nil, settle, nil

nodeOf 与决策分支同函数、同签名,构建选项 + 元素 + buildNode;只读查询,锁外执行安全。审查补充:nodeOf 与结算无数据依赖,先构建可避免「已结算但返回 err」的异常路径。)

审查记录(aea79d8 双审查 APPROVE):

  • SHOULD-FIX(已修):原实现「锁内结算 → 锁外 nodeOf」,nodeOf 失败时已结算却返回 err(logRoute 多写一条终局流水 → FailStreak 误算)。改为 nodeOf 先于 WithLock,失败则未结算。
  • NIT(不修):终局节点无选项,nodeOf 内 NodeOption 查询返回空集,成本极低;FinalNode 透出链路无新增单测,Step 5 既有测试通过即可
  • Step 3: dto FinalSettle 加 FinalNode

biz/model/dto/level_play.go FinalSettle 末尾加:

	FinalNode *NodeVO `json:"final_node"`
  • Step 4: controller 映射

biz/controller/level_play.go Choose 的 res.Final = &dto.FinalSettle{...} 之后加:

		if settle.FinalNode != nil {
			vo := nodeVO(settle.FinalNode)
			res.Final.FinalNode = &vo
		}
  • Step 5: 编译 + 单测

Run: go build ./... && go test ./biz/service/ ./cmd/genasset/ Expected: 全部 PASSSettleFinal 纯函数不受影响)

  • Step 6: Commit
git add biz/service/level_play.go biz/model/dto/level_play.go biz/controller/level_play.go
git commit -m "feat: FinalSettle 增加 final_node——终局节点剧本透出(v2.1 终局演绎数据层)"

Task 9: 前端全自动演出(v2.1

用户走查反馈:情景执行依赖点按钮、文字太多。本任务:开场自动进入、回应气泡自动继续、终局台词先演绎再跳结算、StoryPlayer 已播行折叠。

Files:

  • Modify: ui-src/src/components/SceneTheater.vue

  • Modify: ui-src/src/components/StoryPlayer.vue

  • Modify: ui-src/src/pages/play/play.vue

  • Step 1: SceneTheater 移除「开始」按钮,朗读结束自动 done

SceneTheater.vue 三处修改:

a) 模板删除 theater-actions 整块:

    <view class="theater-actions">
      <view class="btn-primary theater-go" @click="finish">开始 </view>
    </view>

b) mounted 改为朗读结束自动 emit done:

  mounted() {
    this.speaking = true
    speak(this.content, this.audio)
    // v2.1:朗读结束自动进入决策幕(「跳过」仍可手动跳过)
    setTimeout(() => { this.speaking = false; this.$emit('done') }, Math.min(3000, this.content.length * 300))
  },

c) 删除样式(不再有按钮):

.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; }
  • Step 2: StoryPlayer 已播行折叠

StoryPlayer.vue 模板 story-lines 循环改为只保留当前行 + 上一行(v-show 保持 DOM 存活,fading 淡出):

    <view class="story-lines">
      <view v-for="(line, i) in script" :key="i" v-show="i === lineIndex || i === lineIndex - 1" class="story-line" :class="{ active: i === lineIndex, fading: i === lineIndex - 1 }">

CSS .story-line.done 替换为:

.story-line.fading { opacity: 0.35; }

done 类不再被引用,一并删除;active 保持现状。)

  • Step 3: play.vue 终局演绎 + 回复自动继续

play.vue 六处修改:

a) data 加:

      replyTimer: null,
      playingFinal: false,
      finalNode: null,

b) onUnload 清回复计时器:

  onUnload() {
    if (this.branchTimer) clearTimeout(this.branchTimer)
    if (this.replyTimer) clearTimeout(this.replyTimer)
  },

c) computed 加:

    finalScript() {
      return this.finalNode ? parseScript(this.finalNode.script) : null
    }

d) 模板在 v-if="curNode && isStoryNode" 之前插入终局演绎分支(v-if 优先于剧情流,避免终局节点 script 非空时走错分支):

      <!-- v2.1 终局演绎:结局台词播完自动跳结算 -->
      <view v-if="playingFinal && finalNode" class="node story-node final-story">
        <StoryPlayer :key="'final-' + finalNode.node_id" :script="finalScript" :character="finalNode.character" :show-pinyin="showPinyin" @done="gotoResult" />
      </view>

e) submit 的 else if (data.final) 分支替换为:

        } 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
          })
          this.choosing = false
          // v2.1:终局节点有剧本 → 先演绎结局台词再跳结算;无剧本直接跳(回退)
          if (data.final.final_node && parseScript(data.final.final_node.script)) {
            this.finalNode = data.final.final_node
            this.playingFinal = true
          } else {
            playSound('finish')
            uni.redirectTo({ url: '/pages/result/result' })
          }
        }

f) branchTimer 内剧情流分支(this.isStoryNode)加 3 秒自动继续:

            if (this.isStoryNode) {
              this.reply = fb
              this.showOptions = false // 提交后隐藏选项区,防重复点击
              // v2.1:回应气泡约 3 秒自动继续(点击「继续」立即继续)
              this.replyTimer = setTimeout(() => this.autoContinue(), 3000)
            } else {
              this.feedback = fb
            }

g) methods 加 autoContinue / gotoResultcontinuePlay 开头清计时器:

    autoContinue() {
      if (this.reply && !this.choosing) this.continuePlay()
    },
    gotoResult() {
      playSound('finish')
      uni.redirectTo({ url: '/pages/result/result' })
    },
    continuePlay() {
      if (this.replyTimer) { clearTimeout(this.replyTimer); this.replyTimer = 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 = '🤔'
    }
  • Step 4: 构建验证

Run: cd ui-src && npx vite build --mode h5 2>&1 | tail -3 Expected: 构建成功无报错

  • Step 5: H5 端到端走查

后端与 H5 dev server 运行中,走查 http://localhost:5173/#/pages/play/play?level_id=<第1计关卡>(第 1 计剧本已导入):

  1. 开场朗读结束自动进入剧情流(无「开始」按钮);「跳过 ›」立即进入
  2. 剧情流只显示当前行 + 上一行淡出,无 3-6 行堆叠
  3. 选择后回应气泡约 3 秒自动进入下一节点;点击「继续 ›」立即进入;连点不双跳
  4. 走到终局:结局台词先演绎(跳过可直达结算),播完自动跳结算页;结算数据正常(final 已在 RESULT_KEY
  • Step 6: Commit
git add ui-src/src/components/SceneTheater.vue ui-src/src/components/StoryPlayer.vue ui-src/src/pages/play/play.vue
git commit -m "feat: 全自动演出——开场自动进入/回复自动继续/终局台词演绎/已播行折叠(v2.1)"

审查记录(0f8a371 双审查 APPROVE + d571e89 SHOULD-FIX + NIT 合并提交):

  • SHOULD-FIX(已修):终局演绎块与剧情流块是并列 v-if 而非互斥——终局播放期间 curNode && isStoryNode 仍为 true,旧节点块(已播行 + ActionCardchoosing=false 时可点击)残留可见可交互。修复:剧情流块与 v1 卡片块加 !playingFinal 守卫,三块互斥。
  • NIT1(已修):continuePlay 无空守卫,点击与 3s 自动同帧时 reply/feedback 为 null 抛 TypeError → 开头加 if (!this.reply && !this.feedback) return
  • NIT2(已修):SceneTheater 开场计时器不随卸载清理,跳过后残留 done 触发 → minTimer/autoTimer 存字段,beforeUnmount 清理
  • NIT3(已修):开场朗读 3s 上限必然截断长文本(30 字约 8-12s 朗读被砍)→ 改 speak onEnd 驱动自动进入(最短展示 1.2s 防无 TTS 闪退,15s 兜底防 onEnd 缺失卡死)
  • NIT4(已修):终局 StoryPlayer done 与「跳过」双触发 gotoResult → doneJumped 一次性守卫

Task 10: 纯语音演绎 + 估时兜底 + 8080 托管(v2.2

用户走查反馈:① 必须点跳过才出选项(speak onEnd 部分环境不回调,逐句推进靠 20s forceNext 兜底,体验像卡死);② 剧情不需要文字展示区,直接语音播放;③ 前端应与后端共用 :8080。规格:spec「v2.2 修订」章节。

Files:

  • Modify: ui-src/src/components/StoryPlayer.vue(纯语音 + 估时兜底 + 审查加固)

  • Modify: ui-src/src/components/SceneTheater.vue(估时兜底 + 20s 封顶)

  • Modify: ui-src/src/pages/play/play.vue(移除 :show-pinyin 传参)

  • Modify: main.goSetServerRoot 托管前端产物 + /static 素材挂载)

  • Step 1: StoryPlayer 改纯语音演绎

StoryPlayer.vue 整体重写为:无文字展示区(删 RubyText 气泡、逐字高亮、pinyin),保留角色 + mood 表情 + 跳过;句推进改「speak onEnd + 文本长度估时兜底」:

<template>
  <view class="story">
    <view class="story-skip" @click="finish">跳过 </view>

    <view v-if="character" class="story-char">
      <image v-if="character.image" class="story-char-img" :src="character.image" mode="aspectFit" />
      <view v-else class="story-char-avatar" :style="{ background: charColor }">{{ character.name.slice(0, 1) }}</view>
      <view class="story-mood" :class="{ pop: moodTick }">{{ moodEmoji }}</view>
    </view>
    <!-- v2.2 纯语音演绎无文字展示区speak 逐句推进 -->
  </view>
</template>

<script>
import { personVisual } from '../utils/visual.js'
import { speak, stopSpeak } from '../utils/speech.js'

const EMOJI = { normal: '🙂', think: '🤔', happy: '😊', sad: '😢', surprise: '😮' }
const PAUSE_MS = 700 // 句间停顿
const MAX_LINE_MS = 20000 // 单句最长等待(估时兜底也失效时强制推进)

export default {
  name: 'StoryPlayer',
  props: {
    script: { type: Array, default: () => [] }, // [{speaker, text, pinyin, emotion}]
    character: { type: Object, default: null } // {name, image, id}
  },
  emits: ['done'],
  data() {
    return { lineIndex: 0, moodTick: false, timers: [], ended: false, lineDone: { sp: false, fb: false } }
  },
  computed: {
    moodEmoji() {
      const line = this.script[this.lineIndex]
      return EMOJI[(line && line.emotion) || 'normal'] || EMOJI.normal
    },
    charColor() {
      return personVisual(this.character && this.character.id ? this.character.id : 0).color
    }
  },
  mounted() {
    this.playLine(0)
  },
  beforeUnmount() {
    this.clearTimers()
    stopSpeak()
  },
  methods: {
    clearTimers() {
      this.timers.forEach(t => { clearTimeout(t); clearInterval(t) })
      this.timers = []
    },
    after(ms, fn) {
      this.timers.push(setTimeout(fn, ms))
    },
    playLine(i) {
      const line = this.script[i]
      if (!line) {
        this.$emit('done')
        return
      }
      this.lineIndex = i
      this.ended = false
      this.lineDone = { sp: false, fb: false }
      this.moodTick = true
      this.after(350, () => { this.moodTick = false })
      const total = Array.from(line.text).length
      speak(line.text, '', () => {
        this.lineDone.sp = true
        this.tryNext(i)
      })
      // 估时兜底:onEnd 缺失(浏览器静音策略/无语音)时按文本长度估时放行,不卡死
      this.after(Math.max(2500, total * 320 + 1200), () => {
        if (!this.lineDone.sp) {
          this.lineDone.sp = true
          this.tryNext(i)
        }
      })
      this.after(MAX_LINE_MS, () => this.forceNext(i))
    },
    tryNext(i) {
      if (this.ended || this.lineIndex !== i || !this.lineDone.sp) return
      this.ended = true
      this.clearTimers()
      this.after(PAUSE_MS, () => { this.ended = false; this.playLine(i + 1) })
    },
    forceNext(i) {
      if (this.ended || this.lineIndex !== i) return
      this.ended = true
      this.clearTimers()
      stopSpeak()
      this.after(PAUSE_MS, () => { this.ended = false; this.playLine(i + 1) })
    },
    finish() {
      this.ended = true
      this.clearTimers()
      stopSpeak()
      this.$emit('done')
    }
  }
}
</script>

<style scoped>
.story { position: relative; padding: 24rpx 8rpx 32rpx; }
.story-skip { position: fixed; top: calc(140rpx + env(safe-area-inset-top)); right: 24rpx; font-size: 26rpx; color: #a08c74; background: #fff; border-radius: 30rpx; padding: 8rpx 24rpx; z-index: 20; }
.story-char { position: fixed; right: 24rpx; bottom: 48rpx; z-index: 3; }
.story-char-img { width: 140rpx; height: 140rpx; border-radius: 50%; background: #f8f2e8; box-shadow: 0 8rpx 24rpx rgba(91, 70, 54, 0.15); }
.story-char-avatar { width: 140rpx; height: 140rpx; border-radius: 50%; color: #fff; font-size: 64rpx; font-weight: 800; display: flex; align-items: center; justify-content: center; }
.story-mood { position: absolute; right: -10rpx; bottom: -10rpx; font-size: 44rpx; background: #fff; border-radius: 50%; width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; box-shadow: 0 2rpx 8rpx rgba(91, 70, 54, 0.2); }
.story-mood.pop { animation: mood-pop 0.35s ease; }
@keyframes mood-pop { 0% { transform: scale(0.3) rotate(-20deg); } 100% { transform: scale(1) rotate(0); } }
</style>
  • Step 2: play.vue 移除 StoryPlayer 的 :show-pinyin 传参

两处 <StoryPlayer ... :show-pinyin="showPinyin" ...>(剧情流 + 终局演绎)删除 :show-pinyin="showPinyin" 属性。

  • Step 3: main.go 托管前端产物(8080 单端口)

main.gocontroller.Register(s) 后加(须用 SetServerRoot——走查发现 GoFrame v2.10.2 AddStaticPath("/", ...) 因前缀防误匹配守卫 uri[len(prefix)] != '/' 只服务根路径,/assets/* 子路径全部 404;素材目录 ui-src/static/static 前缀单独挂载,与后端 scene.image 返回的 /static/generated/... URL 对应):

	// 生产形态:前端产物 ui-src/dist 由后端 :8080 统一托管(前后端同端口)
	if _, err := os.Stat("ui-src/dist"); err == nil {
		s.SetServerRoot("ui-src/dist")
	}
	if _, err := os.Stat("ui-src/static"); err == nil {
		s.AddStaticPath("/static", "ui-src/static")
	}
  • Step 4: 构建 + 验证

Run: cd ui-src && npx vite build --mode h5 2>&1 | tail -3 && cd .. && go build ./... Expected: 构建成功;产物在 ui-src/dist

重启后端后走查 http://localhost:8080H5 构建,前后端同端口):

  1. 打开即应用首页(非 404
  2. 第 1 计关卡:剧情纯语音无文字区;台词播完选项自动出现(不点跳过)
  3. 模拟 onEnd 不回调(playwright 注入 stub speechSynthesisspeak 不发声、不发事件):逐句按估时兜底推进,选项最终自动出现,不卡死
  4. 终局台词演绎后自动跳结算(8080 端口同样生效)
  • Step 5: Commit
git add ui-src/src/components/StoryPlayer.vue ui-src/src/pages/play/play.vue main.go
git commit -m "feat: 剧情纯语音演绎(无文字区)+ 句推进估时兜底 + 前端产物由 :8080 托管(v2.2"

审查记录(a775fc7 + 7386715 双审查 APPROVE + SHOULD-FIX 合并修复):

  • 走查发现 AddStaticPath("/", ...) 前缀守卫只服务根路径 → SetServerRoot + /static 挂载(main.go
  • SHOULD-FIX 1speak 抛异常时兜底定时器未注册 → 定时器前置 + try/catchStoryPlayer playLine
  • SHOULD-FIX 2:迟到 onEnd 提前放行当前句 → 行号守卫(lineIndex===i && !ended)加在 onEnd 与估时闭包
  • SHOULD-FIX 3(记录待办):Dockerfile/docker-compose.yml 缺失——ui-src/dist 与 ui-src/static 均不入 git,全新环境 :8080 空白页;待 Task 7/10 素材管线收尾后补部署链
  • NITlineDone.fb 死字段删除;SceneTheater 估时封顶 20s;注释同步
  • playwright 双场景走查(:8080addInitScript stub speechSynthesis + uni 格式 storage 种子):场景A onEnd 正常回调 / 场景B onEnd 永不回调,均「剧情无文字区 + 选项自动出现(未点跳过)」PASS

验收对照(spec v2 + v2.1 + v2.2

验收标准 任务
决策节点台词流演绎(气泡/朗读/逐字高亮/表情,末句提问) Task 3 + Task 6
ActionCard 嵌入剧情流(触控 5 种同) Task 4
角色回应气泡融入剧情流,无弹窗 Task 4 Step 3
script 空回退 v1 呈现 Task 4 Step 2v-else 分支)
管线生成→精修→导入→前端生效;校验拒绝不合规草稿 Task 5 + Task 6
level/choose 判分与完美机制不变 Task 1(仅加列透出);Task 6 Step 5 走查确认
开场自动进入决策幕(无「开始」按钮),跳过可用 Task 9 Step 1
回应气泡约 3 秒自动继续,点击立即继续,无重复推进 Task 9 Step 3
终局台词先演绎再自动跳结算;final_node.script 空直接跳 Task 8 + Task 9 Step 3
StoryPlayer 已播行折叠(当前行 + 上一行淡出) Task 9 Step 2
验收标准 任务
决策节点台词流演绎(气泡/朗读/逐字高亮/表情,末句提问) Task 3 + Task 6
ActionCard 嵌入剧情流(触控 5 种同) Task 4
角色回应气泡融入剧情流,无弹窗 Task 4 Step 3
script 空回退 v1 呈现 Task 4 Step 2v-else 分支)
管线生成→精修→导入→前端生效;校验拒绝不合规草稿 Task 5 + Task 6
level/choose 判分与完美机制不变 Task 1(仅加列透出);Task 6 Step 5 走查确认