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

1589 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 互动剧情演绎 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` 字段后加:
```go
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
```go
config TEXT,
script TEXT,
```
```go
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` 后加:
```go
Config string `json:"config"`
Script string `json:"script"`
```
- [ ] **Step 4: service buildNode 透出**
`service/level.go` buildNode 的 Node 结构体加字段:
```go
Config string
Script string
```
并在 `buildNode` 内 `Config: nodeRec["config"].String(),` 后加:
```go
Script: nodeRec["script"].String(),
```
- [ ] **Step 5: controller nodeVO 透出**
`controller/level.go` 的 nodeVO 中 `Config: n.Config,` 后加:
```go
Script: n.Script,
```
- [ ] **Step 6: 编译 + 既有单测验证**
Run: `go build ./... && go test ./biz/service/ ./common/`
Expected: 全部 PASSlevel_play / pinyin 单测不受影响)
- [ ] **Step 7: Commit**
```bash
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` 末尾追加:
```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 加:
```js
highlight: { type: Number, default: -1 }, // >=0 逐字高亮到该下标;-1 不启用
```
模板中 ruby-ch 加条件 class`pairs` 的 `i` 即字下标):
```html
<text class="ruby-ch" :class="{ 'ruby-hl': highlight >= 0 && i < highlight, 'ruby-cur': i === highlight }">{{ ch.c }}</text>
```
样式加:
```css
.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**
```bash
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 慢时切掉上句音频):
```js
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`
```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**
```bash
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` 行后):
```js
import StoryPlayer from '../../components/StoryPlayer.vue'
```
`pickInteraction` import 行改为同时引入 parseScript
```js
import { pickInteraction, entryProps, entryCharacter, parseScript } from '../../utils/theater.js'
```
components 注册加 `StoryPlayer`。
data() 加:
```js
showOptions: false,
reply: null,
```
computed 加:
```js
storyScript() {
return this.curNode ? parseScript(this.curNode.script) : null
},
isStoryNode() {
return !!this.storyScript
}
```
- [ ] **Step 2: 模板加剧情流分支**
在 `<!-- 节点:互动分发 -->` 处,把现有 `<view v-if="curNode" class="node card">…</view>` 整体改为:
```html
<!-- 节点:剧情流(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) { … }` 分支整体替换为:
```js
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()` 替换为:
```js
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 区追加:
```css
.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 需在运行;命令在项目根目录执行):
```bash
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**
```bash
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` 末尾追加:
```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`
```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`
```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 加字段:
```go
importScripts bool
```
parseFlags 加:
```go
flag.BoolVar(&fl.importScripts, "import-scripts", false, "从草稿导入剧本到数据库(--strategy 限定计策)")
```
main() 的分发逻辑替换为:
```go
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` 行后加:
```go
fmt.Printf("待生成剧本节点:%d\n", len(pendingScriptNodes(fl)))
```
文件头注释 `// --only=comments` 行后加两行:
```go
// 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**
```bash
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 个节点`
验证回填:
```bash
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**
```bash
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**
```bash
git add README.md 技术设计.md workspace/scripts/
git commit -m "docs: 剧情演绎文档补充 + 全量剧本生成导入"
```
---
### Task 8: 后端 FinalSettle.final_nodev2.1 终局演绎数据层)
> v2.1choose 到终局时,结局台词目前从未播放(前端直接跳结算页)。本任务让终局节点(含 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 末尾加:
```go
FinalNode *Node // 终局节点(含 script,v2.1 前端演绎结局台词后跳结算)
```
- [ ] **Step 2: Choose 终局分支返回终局 Node**
`biz/service/level_play.go` Choose 终局分支改为**先 nodeOf 再结算**(内容表只读查询失败则未结算,无副作用;与决策分支同顺序):
```go
// 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 末尾加:
```go
FinalNode *NodeVO `json:"final_node"`
```
- [ ] **Step 4: controller 映射**
`biz/controller/level_play.go` Choose 的 `res.Final = &dto.FinalSettle{...}` 之后加:
```go
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**
```bash
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` 整块:
```html
<view class="theater-actions">
<view class="btn-primary theater-go" @click="finish">开始 </view>
</view>
```
b) mounted 改为朗读结束自动 emit done
```js
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) 删除样式(不再有按钮):
```css
.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 淡出):
```html
<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` 替换为:
```css
.story-line.fading { opacity: 0.35; }
```
`done` 类不再被引用,一并删除;`active` 保持现状。)
- [ ] **Step 3: play.vue 终局演绎 + 回复自动继续**
`play.vue` 六处修改:
a) data 加:
```js
replyTimer: null,
playingFinal: false,
finalNode: null,
```
b) onUnload 清回复计时器:
```js
onUnload() {
if (this.branchTimer) clearTimeout(this.branchTimer)
if (this.replyTimer) clearTimeout(this.replyTimer)
},
```
c) computed 加:
```js
finalScript() {
return this.finalNode ? parseScript(this.finalNode.script) : null
}
```
d) 模板在 `v-if="curNode && isStoryNode"` 之前插入终局演绎分支(v-if 优先于剧情流,避免终局节点 script 非空时走错分支):
```html
<!-- 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)` 分支替换为:
```js
} 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 秒自动继续:
```js
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` 开头清计时器:
```js
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**
```bash
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.go`SetServerRoot 托管前端产物 + /static 素材挂载)
- [ ] **Step 1: StoryPlayer 改纯语音演绎**
`StoryPlayer.vue` 整体重写为:无文字展示区(删 RubyText 气泡、逐字高亮、pinyin),保留角色 + mood 表情 + 跳过;句推进改「speak onEnd + 文本长度估时兜底」:
```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>
<!-- 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 对应):
```go
// 生产形态:前端产物 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 构建,前后端同端口):
1. 打开即应用首页(非 404
2. 第 1 计关卡:剧情纯语音无文字区;台词播完选项自动出现(不点跳过)
3. 模拟 onEnd 不回调(playwright 注入 stub speechSynthesisspeak 不发声、不发事件):逐句按估时兜底推进,选项最终自动出现,不卡死
4. 终局台词演绎后自动跳结算(8080 端口同样生效)
- [ ] **Step 5: Commit**
```bash
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 走查确认 |