diff --git a/docs/superpowers/plans/2026-08-13-story-theater-v2.md b/docs/superpowers/plans/2026-08-13-story-theater-v2.md
new file mode 100644
index 0000000..f2238c6
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-13-story-theater-v2.md
@@ -0,0 +1,1040 @@
+# 互动剧情演绎 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` |
+| `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: 编译 + 既有单测验证**
+
+Run: `go build ./... && go test ./biz/service/ ./common/`
+Expected: 全部 PASS(level_play / pinyin 单测不受影响)
+
+- [ ] **Step 6: 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
+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` 末尾追加:
+
+```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
+ {{ ch.c }}
+```
+
+样式加:
+
+```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` 也可省略,仅做语法验证)
+
+- [ ] **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`
+
+- [ ] **Step 1: 创建组件**
+
+完整创建 `ui-src/src/components/StoryPlayer.vue`:
+
+```vue
+
+
+ 跳过 ›
+
+
+
+ {{ character.name.slice(0, 1) }}
+ {{ moodEmoji }}
+
+
+
+
+
+ 📖 旁白
+
+
+
+ {{ line.speaker }}
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: 语法验证**
+
+Run: `cd ui-src && npx vite build --mode h5 2>&1 | tail -3`
+Expected: 构建成功(组件未被引用,仅语法检查)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add ui-src/src/components/StoryPlayer.vue
+git commit -m "feat: StoryPlayer 台词流演绎器(气泡+朗读+逐字高亮+表情切换+跳过)"
+```
+
+---
+
+### 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: 模板加剧情流分支**
+
+在 `` 处,把现有 `…` 整体改为:
+
+```html
+
+
+
+
+
+
+
+
+
+
+ 该互动玩法(类型 {{ curNode.interaction_type }})敬请期待
+
+
+
+ 🧑🎓 你的选择
+
+ ✓{{ reply.pros }}
+ ✗{{ reply.cons }}
+
+ {{ reply.prop.name }}
+ {{ reply.prop.description }}
+
+
+ 继续 ›
+
+
+
+
+
+```
+
+(原节点卡片内触控组件分发、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
+ } 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)"
+```
+
+---
+
+### 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. 每句可标注情绪 emotion(normal/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"},
+ }
+ 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 {
+ 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).Data(g.Map{"script": string(b2)}).Where("id", d.NodeID).Update(ctx); 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_* 全部 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**
+
+```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 校验注音回填"
+```
+
+---
+
+### 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`
+检查:
+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: 剧情演绎文档补充 + 全量剧本生成导入"
+```
+
+---
+
+## 验收对照(spec v2)
+
+| 验收标准 | 任务 |
+|---|---|
+| 决策节点台词流演绎(气泡/朗读/逐字高亮/表情,末句提问) | 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 走查确认 |