60 KiB
互动剧情演绎 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.go 的 Config 字段后加:
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
并在 buildNode 内 Config: 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: 全部 PASS(level_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 剧本列并透出 NodeVO(v2 剧情演绎数据层)"
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 加条件 class(pairs 的 i 即字下标):
<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.js(speak 加 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 改 fixed(top 140rpx 避开 topbar)
- NIT(已合并修):moodTick 300→350ms 复位(动画不截断);speaking 死状态删除
- NIT(记录待 Task 6 走查):.story-char fixed 角色立牌可能与选项区重叠,走查确认,必要时降 absolute;character.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;"):
- 剧情流显示两句气泡(旁白居中 + 角色提问),逐字高亮推进、自动朗读、mood 徽章 🤔
- 播完(或点跳过)ActionCard 出现在剧情流下方
- 选择后 0.6s 回应气泡融入剧情流(✓/✗ 两行 + 继续按钮),无遮罩弹窗
- 点「继续」进入下一节点剧情流
- 其他关卡(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 无
scriptwatch、play.vue 无:key,节点切换时 v-if 恒真组件不重建不重播,done永不触发 → 选项区永不出现。修复:<StoryPlayer :key="curNode.node_id" …/>强制按节点重建重播- SHOULD-FIX(已修):reply 出现后 showOptions 仍 true,ActionCard 仍可点击 → 可重复 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. 每句可标注情绪 emotion(normal/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_* 全部 PASS(6 个);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 N(3-6 句) ×第 1 计节点数(约 5-8 个);本地模型每节点 2-4 分钟,总耗时约 10-30 分钟
- Step 2: 检查失败与草稿质量
Run: ls workspace/scripts/1/ && cat workspace/scripts/1/*.json | head -60
检查:
- 每个节点有草稿文件(校验失败的节点会有日志,可
--force重跑——先修 prompt 或手工改草稿) - 台词符合儿童口语、末句提问(决策节点)/ 总结(终局节点)、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;"):
- 开场 SceneTheater 不变,点击「开始」进入剧情流
- 每句:气泡 + 自动朗读 + 逐字高亮(含拼音)+ 角色 mood 表情切换;播完提问句后 ActionCard 出现
- 选择 → 分支演出(晃动/音效)→ 回应气泡 ✓/✗ 融入剧情流 → 继续 → 下一节点继续演绎
- 走到终局节点:结局台词演绎 → 结算页(既有 confetti)
- 连走 3 关(第 1 计 3 个关卡)确认无 console 报错
- 走一关 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_node(v2.1 终局演绎数据层)
v2.1:choose 到终局时,结局台词目前从未播放(前端直接跳结算页)。本任务让终局节点(含 script)随 FinalSettle 一并返回,前端 Task 9 先演绎再跳结算。
Files:
-
Modify:
biz/service/level_play.go:32-43(FinalSettle struct) -
Modify:
biz/service/level_play.go:149-166(Choose 终局分支) -
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: 全部 PASS(SettleFinal 纯函数不受影响)
- 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 / gotoResult,continuePlay 开头清计时器:
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 计剧本已导入):
- 开场朗读结束自动进入剧情流(无「开始」按钮);「跳过 ›」立即进入
- 剧情流只显示当前行 + 上一行淡出,无 3-6 行堆叠
- 选择后回应气泡约 3 秒自动进入下一节点;点击「继续 ›」立即进入;连点不双跳
- 走到终局:结局台词先演绎(跳过可直达结算),播完自动跳结算页;结算数据正常(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 +
d571e89SHOULD-FIX + NIT 合并提交):
- SHOULD-FIX(已修):终局演绎块与剧情流块是并列 v-if 而非互斥——终局播放期间
curNode && isStoryNode仍为 true,旧节点块(已播行 + ActionCard,choosing=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.go(SetServerRoot 托管前端产物 + /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.go 在 controller.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:8080(H5 构建,前后端同端口):
- 打开即应用首页(非 404)
- 第 1 计关卡:剧情纯语音无文字区;台词播完选项自动出现(不点跳过)
- 模拟 onEnd 不回调(playwright 注入 stub speechSynthesis:speak 不发声、不发事件):逐句按估时兜底推进,选项最终自动出现,不卡死
- 终局台词演绎后自动跳结算(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 1:speak 抛异常时兜底定时器未注册 → 定时器前置 + try/catch(StoryPlayer 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 素材管线收尾后补部署链
- NIT:lineDone.fb 死字段删除;SceneTheater 估时封顶 20s;注释同步
- playwright 双场景走查(:8080,addInitScript 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 2(v-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 2(v-else 分支) |
| 管线生成→精修→导入→前端生效;校验拒绝不合规草稿 | Task 5 + Task 6 |
| level/choose 判分与完美机制不变 | Task 1(仅加列透出);Task 6 Step 5 走查确认 |