1589 lines
60 KiB
Markdown
1589 lines
60 KiB
Markdown
# 互动剧情演绎 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: 全部 PASS(level_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 剧本列并透出 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
|
||
<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 改 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` 行后):
|
||
|
||
```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 仍 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` 末尾追加:
|
||
|
||
```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"},
|
||
{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_* 全部 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 校验注音回填"
|
||
```
|
||
|
||
> 审查记录(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`
|
||
检查:
|
||
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_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 末尾加:
|
||
|
||
```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: 全部 PASS(SettleFinal 纯函数不受影响)
|
||
|
||
- [ ] **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,旧节点块(已播行 + 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 + 文本长度估时兜底」:
|
||
|
||
```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 speechSynthesis:speak 不发声、不发事件):逐句按估时兜底推进,选项最终自动出现,不卡死
|
||
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 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 走查确认 |
|