docs: 故事剧场化实现计划(Task 1-6:theater.js/ActionCard/SceneTheater/play+result 集成/文档)
This commit is contained in:
@@ -0,0 +1,758 @@
|
||||
# 故事剧场化实现计划(三幕演出 + 动作卡全互动呈现)
|
||||
|
||||
> **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:** 把每关从"文字问答"升级为"儿童小剧场":开场演出(旁白+字幕淡入+角色道具入场)、决策动作卡(替代文字选项列表)、分支演出(选择后动画过渡)、结局演出(庆祝/鼓励动画),决策树数据模型零改动。
|
||||
|
||||
**Architecture:** 纯前端呈现层升级。新增 `theater.js`(互动形态判定 + 演出数据提取纯函数)、`ActionCard.vue`(动作卡)、`SceneTheater.vue`(开场演出覆盖层);改造 `play.vue`(开场集成 + 动作卡 + 分支演出)与 `result.vue`(结局演出)。触控互动(PropPick 等 5 种)仅用于已有 config 互动节点;普通决策节点一律用动作卡呈现(**计划级修正**,见下)。全部 CSS 动画,无新依赖。
|
||||
|
||||
**Tech Stack:** uni-app (Vue 3) H5 先行、现有 RubyText/SpeakButton/speech.js/visual.js/sound.js、CSS keyframes。
|
||||
|
||||
**对规格的修正(重要)**:spec 中"按选项内容特征自动判定互动形态 a-e"与现有交互协议冲突——触控互动组件只有 success/fail 双出口(提交 options[0]/options[1]),而普通决策节点的 2-4 个选项各自指向独立分支,前端无法静态判定"最佳选项"。修正:普通决策节点(interaction_type=1)一律渲染动作卡(多分支语义完整保留),触控互动仅用于 config 非空的互动节点。全 36 关文字选项列表消灭的目标不变(动作卡兜底所有普通节点)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `ui-src/src/utils/theater.js`(互动判定 + 演出数据提取)
|
||||
|
||||
**Files:**
|
||||
- Create: `ui-src/src/utils/theater.js`
|
||||
|
||||
- [ ] **Step 1: 写文件**
|
||||
|
||||
```js
|
||||
// 剧场化工具:互动形态判定 + 开场演出数据提取(决策树模型零改动)
|
||||
|
||||
const INTERACTION_MAP = { 2: 'PropPick', 3: 'StepSort', 5: 'DragPlace', 7: 'FindSpot', 8: 'LinkMatch' }
|
||||
|
||||
// 互动形态判定:config 互动节点 → 对应触控组件;普通决策节点 → 动作卡
|
||||
// (触控组件只有 success/fail 双出口,普通节点多分支选项语义由 ActionCard 完整保留)
|
||||
export function pickInteraction(node) {
|
||||
if (node && node.config && INTERACTION_MAP[node.interaction_type]) {
|
||||
return INTERACTION_MAP[node.interaction_type]
|
||||
}
|
||||
return 'ActionCard'
|
||||
}
|
||||
|
||||
// 开场演出道具:入口节点选项的道具名去重,最多 4 个
|
||||
export function entryProps(entry) {
|
||||
const names = []
|
||||
for (const o of (entry && entry.options) || []) {
|
||||
if (o.prop && o.prop.name && !names.includes(o.prop.name)) {
|
||||
names.push(o.prop.name)
|
||||
if (names.length >= 4) break
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// 开场演出角色:入口节点人物(可能为空)
|
||||
export function entryCharacter(entry) {
|
||||
return entry && entry.character ? entry.character : null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: H5 编译检查**
|
||||
|
||||
Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/utils/theater.js` 返回 200。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add ui-src/src/utils/theater.js
|
||||
git commit -m "feat: 剧场化工具 theater.js(互动形态判定 + 开场演出数据提取)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `ui-src/src/components/ActionCard.vue`(动作卡组件)
|
||||
|
||||
**Files:**
|
||||
- Create: `ui-src/src/components/ActionCard.vue`
|
||||
|
||||
动作卡 = 现有选项列表的升级呈现:每选项一张卡(序号徽章 + 道具图标/emoji + 短语拼音注音 + 听一听 + 按压弹跳 + c1-c4 四色边框)。
|
||||
|
||||
- [ ] **Step 1: 写组件**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view class="action-cards">
|
||||
<view
|
||||
v-for="(o, idx) in node.options"
|
||||
:key="o.option_id"
|
||||
class="action-card"
|
||||
:class="['c' + (idx + 1), { disabled: disabled }]"
|
||||
@click="pick(o)"
|
||||
>
|
||||
<view class="ac-mark">{{ ['A', 'B', 'C', 'D'][idx] }}</view>
|
||||
<view v-if="o.prop" class="ac-icon">
|
||||
<image v-if="o.prop.image" class="ac-icon-img" :src="o.prop.image" mode="aspectFit" />
|
||||
<text v-else class="ac-icon-emoji">{{ propEmoji(o.prop.name) }}</text>
|
||||
</view>
|
||||
<view class="ac-text">
|
||||
<RubyText :text="o.text" :pinyin="o.text_pinyin" :show="showPinyin" />
|
||||
</view>
|
||||
<view class="ac-listen" @click.stop="listen(o)">🔊</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RubyText from '../RubyText.vue'
|
||||
import { propVisual } from '../utils/visual.js'
|
||||
import { speak } from '../utils/speech.js'
|
||||
|
||||
export default {
|
||||
name: 'ActionCard',
|
||||
components: { RubyText },
|
||||
props: {
|
||||
node: { type: Object, required: true }, // 决策节点(options 数组)
|
||||
showPinyin: { type: Boolean, default: true },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['choose'],
|
||||
methods: {
|
||||
pick(o) {
|
||||
if (this.disabled) return
|
||||
this.$emit('choose', o)
|
||||
},
|
||||
listen(o) {
|
||||
speak(o.text, o.audio)
|
||||
},
|
||||
propEmoji(name) {
|
||||
return propVisual(name).emoji
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-cards { display: flex; flex-direction: column; gap: 20rpx; }
|
||||
.action-card { display: flex; align-items: center; background: #f8f2e8; border: 6rpx solid transparent; border-radius: 24rpx; padding: 26rpx 28rpx; transition: transform 0.15s; }
|
||||
.action-card.disabled { opacity: 0.6; }
|
||||
.action-card:active { transform: scale(0.96); }
|
||||
.action-card.c1 { border-color: #ff9a3d; }
|
||||
.action-card.c2 { border-color: #4ecdc4; }
|
||||
.action-card.c3 { border-color: #6c8cff; }
|
||||
.action-card.c4 { border-color: #a58cff; }
|
||||
.ac-mark { width: 56rpx; height: 56rpx; border-radius: 50%; background: #ff9a3d; color: #fff; font-weight: 800; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
|
||||
.ac-icon { width: 88rpx; height: 88rpx; background: #fff7ec; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
|
||||
.ac-icon-img { width: 64rpx; height: 64rpx; }
|
||||
.ac-icon-emoji { font-size: 48rpx; }
|
||||
.ac-text { flex: 1; font-size: 30rpx; font-weight: 600; color: #5b4636; }
|
||||
.ac-listen { font-size: 36rpx; width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; background: #fff0e5; border-radius: 50%; margin-left: 16rpx; flex-shrink: 0; }
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: H5 编译检查**
|
||||
|
||||
Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/ActionCard.vue` 返回 200。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add ui-src/src/components/ActionCard.vue
|
||||
git commit -m "feat: 动作卡组件 ActionCard(选项图标/拼音/听读/按压动画/四色边框)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `ui-src/src/components/SceneTheater.vue`(开场演出覆盖层)
|
||||
|
||||
**Files:**
|
||||
- Create: `ui-src/src/components/SceneTheater.vue`
|
||||
|
||||
开场演出:场景横幅 + 角色滑入 + 道具飞入 + 旁白自动朗读 + 字幕逐字淡入 + 跳过按钮。演出播完(或点跳过)后 emit done,父组件隐藏本层。
|
||||
|
||||
- [ ] **Step 1: 写组件**
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<view class="theater" :style="{ background: sceneColor + 'ee' }">
|
||||
<image v-if="sceneImage" class="theater-bg" :src="sceneImage" mode="aspectFill" />
|
||||
<view class="theater-skip" @click="finish">跳过 ›</view>
|
||||
|
||||
<view class="theater-scene">
|
||||
<view class="theater-emoji">{{ sceneEmoji }}</view>
|
||||
<view class="theater-name">{{ sceneName }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 道具飞入(右侧逐个) -->
|
||||
<view class="theater-props">
|
||||
<view
|
||||
v-for="(p, i) in props"
|
||||
:key="i"
|
||||
class="theater-prop"
|
||||
:style="{ animationDelay: 0.6 + i * 0.3 + 's' }"
|
||||
>{{ propEmoji(p) }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 角色滑入(左侧) -->
|
||||
<view v-if="character" class="theater-char">
|
||||
<image v-if="character.image" class="theater-char-img" :src="character.image" mode="aspectFit" />
|
||||
<view v-else class="theater-char-avatar" :style="{ background: charColor }">{{ character.name.slice(0, 1) }}</view>
|
||||
<view class="theater-char-mood">🤔</view>
|
||||
</view>
|
||||
|
||||
<!-- 旁白字幕(逐字淡入 + 拼音) -->
|
||||
<view class="theater-sub card">
|
||||
<view class="theater-sub-head">
|
||||
<text class="theater-sub-label">📖 情境</text>
|
||||
<text class="theater-sub-state">{{ speaking ? '正在朗读…' : '' }}</text>
|
||||
</view>
|
||||
<view class="theater-sub-ruby">
|
||||
<view
|
||||
v-for="(ch, i) in pairs"
|
||||
:key="i"
|
||||
class="theater-unit"
|
||||
:style="{ animationDelay: i * 0.05 + 's' }"
|
||||
>
|
||||
<text v-if="ch.p && showPinyin" class="theater-py">{{ ch.p }}</text>
|
||||
<text class="theater-ch">{{ ch.c }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="theater-actions">
|
||||
<view class="btn-primary theater-go" @click="finish">开始 ›</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { parsePinyin, zipText } from '../utils/pinyin.js'
|
||||
import { propVisual, personVisual } from '../utils/visual.js'
|
||||
import { speak, stopSpeak } from '../utils/speech.js'
|
||||
|
||||
export default {
|
||||
name: 'SceneTheater',
|
||||
props: {
|
||||
sceneName: { type: String, default: '' },
|
||||
sceneEmoji: { type: String, default: '🏞️' },
|
||||
sceneColor: { type: String, default: '#ffd08a' },
|
||||
sceneImage: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
contentPinyin: { type: String, default: '' },
|
||||
audio: { type: String, default: '' },
|
||||
character: { type: Object, default: null }, // {name, image, id}
|
||||
props: { type: Array, default: () => [] }, // 道具名数组
|
||||
showPinyin: { type: Boolean, default: true }
|
||||
},
|
||||
emits: ['done'],
|
||||
data() {
|
||||
return { speaking: false }
|
||||
},
|
||||
computed: {
|
||||
pairs() {
|
||||
return zipText(this.content, parsePinyin(this.contentPinyin))
|
||||
},
|
||||
charColor() {
|
||||
return personVisual(this.character && this.character.id ? this.character.id : 0).color
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.speaking = true
|
||||
speak(this.content, this.audio)
|
||||
setTimeout(() => { this.speaking = false }, Math.min(3000, this.content.length * 300))
|
||||
},
|
||||
beforeUnmount() {
|
||||
stopSpeak()
|
||||
},
|
||||
methods: {
|
||||
finish() {
|
||||
stopSpeak()
|
||||
this.$emit('done')
|
||||
},
|
||||
propEmoji(name) {
|
||||
return propVisual(name).emoji
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.theater { position: fixed; inset: 0; z-index: 30; padding: 120rpx 48rpx 80rpx; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.theater-bg { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.theater-skip { position: absolute; top: calc(24rpx + env(safe-area-inset-top)); right: 32rpx; font-size: 28rpx; color: #5b4636; background: rgba(255, 255, 255, 0.8); border-radius: 30rpx; padding: 10rpx 28rpx; z-index: 2; }
|
||||
.theater-scene { display: flex; align-items: center; justify-content: center; gap: 16rpx; margin-bottom: 48rpx; animation: t-pop 0.6s ease; }
|
||||
.theater-emoji { font-size: 88rpx; }
|
||||
.theater-name { font-size: 44rpx; font-weight: 800; color: #5b4636; }
|
||||
@keyframes t-pop { 0% { opacity: 0; transform: scale(0.5); } 70% { transform: scale(1.1); } 100% { opacity: 1; transform: scale(1); } }
|
||||
.theater-props { position: absolute; top: 40%; right: 32rpx; display: flex; flex-direction: column; gap: 24rpx; z-index: 1; }
|
||||
.theater-prop { font-size: 64rpx; opacity: 0; animation: prop-fly 0.8s ease forwards; }
|
||||
@keyframes prop-fly { 0% { opacity: 0; transform: translateX(140rpx) rotate(40deg); } 70% { transform: translateX(-16rpx) rotate(-10deg); } 100% { opacity: 1; transform: translateX(0) rotate(0); } }
|
||||
.theater-char { position: absolute; left: 32rpx; bottom: 36%; z-index: 1; opacity: 0; animation: char-in 0.9s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s forwards; }
|
||||
.theater-char-img { width: 180rpx; height: 180rpx; }
|
||||
.theater-char-avatar { width: 160rpx; height: 160rpx; border-radius: 50%; color: #fff; font-size: 72rpx; font-weight: 800; display: flex; align-items: center; justify-content: center; }
|
||||
.theater-char-mood { position: absolute; right: -16rpx; bottom: -16rpx; font-size: 56rpx; }
|
||||
@keyframes char-in { 0% { opacity: 0; transform: translateX(-220rpx); } 70% { transform: translateX(16rpx); } 100% { opacity: 1; transform: translateX(0); } }
|
||||
.theater-sub { margin-top: auto; padding: 32rpx 36rpx; position: relative; z-index: 2; }
|
||||
.theater-sub-head { display: flex; justify-content: space-between; margin-bottom: 12rpx; }
|
||||
.theater-sub-label { font-size: 26rpx; color: #a08c74; font-weight: 700; }
|
||||
.theater-sub-state { font-size: 24rpx; color: #ff6b35; }
|
||||
.theater-sub-ruby { display: flex; flex-wrap: wrap; line-height: 1.9; }
|
||||
.theater-unit { display: flex; flex-direction: column; align-items: center; margin: 0 2rpx; opacity: 0; animation: t-word 0.4s ease forwards; }
|
||||
@keyframes t-word { 0% { opacity: 0; transform: translateY(12rpx); } 100% { opacity: 1; transform: translateY(0); } }
|
||||
.theater-py { font-size: 20rpx; color: #ff6b35; line-height: 1.3; height: 28rpx; }
|
||||
.theater-ch { font-size: 34rpx; line-height: 1.5; color: #5b4636; }
|
||||
.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; }
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: H5 编译检查**
|
||||
|
||||
Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/components/SceneTheater.vue` 返回 200。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add ui-src/src/components/SceneTheater.vue
|
||||
git commit -m "feat: 开场演出组件 SceneTheater(旁白朗读+字幕逐字淡入+角色入场+道具飞入+跳过)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `play.vue` 集成(开场演出 + 动作卡 + 分支演出)
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui-src/src/pages/play/play.vue`
|
||||
|
||||
- [ ] **Step 1: script 部分改造**
|
||||
|
||||
`<script>` 段整体替换为:
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import { api } from '../../api/index.js'
|
||||
import { getChildId, getChildAge } from '../../store/user.js'
|
||||
import { sceneVisual, personVisual } from '../../utils/visual.js'
|
||||
import { playSound, isSoundEnabled, setSoundEnabled } from '../../utils/sound.js'
|
||||
import { pickInteraction, entryProps, entryCharacter } from '../../utils/theater.js'
|
||||
import RubyText from '../../components/RubyText.vue'
|
||||
import SpeakButton from '../../components/SpeakButton.vue'
|
||||
import SceneTheater from '../../components/SceneTheater.vue'
|
||||
import ActionCard from '../../components/ActionCard.vue'
|
||||
import PropPick from '../../components/interactions/PropPick.vue'
|
||||
import FindSpot from '../../components/interactions/FindSpot.vue'
|
||||
import DragPlace from '../../components/interactions/DragPlace.vue'
|
||||
import StepSort from '../../components/interactions/StepSort.vue'
|
||||
import LinkMatch from '../../components/interactions/LinkMatch.vue'
|
||||
|
||||
const RESULT_KEY = '36wisdom_result'
|
||||
|
||||
export default {
|
||||
components: { RubyText, SpeakButton, SceneTheater, ActionCard, PropPick, FindSpot, DragPlace, StepSort, LinkMatch },
|
||||
data() {
|
||||
return {
|
||||
childId: getChildId(),
|
||||
levelId: 0,
|
||||
detail: null,
|
||||
curNode: null,
|
||||
feedback: null,
|
||||
choosing: false,
|
||||
showPinyin: getChildAge() === '4-6',
|
||||
routeSteps: [],
|
||||
mood: '🤔',
|
||||
combo: 0,
|
||||
soundOn: isSoundEnabled(),
|
||||
showIntro: false,
|
||||
sceneFx: ''
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
this.levelId = Number(options.level_id || 0)
|
||||
this.load()
|
||||
},
|
||||
computed: {
|
||||
scene() {
|
||||
return sceneVisual(this.detail && this.detail.scene ? this.detail.scene.name : '')
|
||||
},
|
||||
sceneImg() {
|
||||
return this.detail && this.detail.scene && this.detail.scene.image ? this.detail.scene.image : ''
|
||||
},
|
||||
charImg() {
|
||||
return this.curNode && this.curNode.character && this.curNode.character.image ? this.curNode.character.image : ''
|
||||
},
|
||||
stepCount() {
|
||||
return Math.min(this.routeSteps.length + 1, 6)
|
||||
},
|
||||
activeInteraction() {
|
||||
return this.curNode ? pickInteraction(this.curNode) : ''
|
||||
},
|
||||
introCharacter() {
|
||||
return this.detail ? entryCharacter(this.detail.entry) : null
|
||||
},
|
||||
introProps() {
|
||||
return this.detail ? entryProps(this.detail.entry) : []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async load() {
|
||||
try {
|
||||
const data = await api.levelDetail(this.childId, this.levelId)
|
||||
this.detail = data
|
||||
this.curNode = data.entry
|
||||
this.showIntro = true
|
||||
} catch (e) {
|
||||
setTimeout(() => this.goBack(), 800)
|
||||
}
|
||||
},
|
||||
goBack() {
|
||||
uni.navigateBack({ fail: () => uni.reLaunch({ url: '/pages/map/map' }) })
|
||||
},
|
||||
personColor(id) {
|
||||
return personVisual(id).color
|
||||
},
|
||||
// 触控互动判定完成:提交成功/失败出口选项
|
||||
interactionDone({ success }) {
|
||||
this.mood = success ? '😊' : '😢'
|
||||
const opt = success ? this.curNode.options[0] : this.curNode.options[1]
|
||||
if (opt) this.submit(opt)
|
||||
},
|
||||
toggleSound() {
|
||||
this.soundOn = !this.soundOn
|
||||
setSoundEnabled(this.soundOn)
|
||||
},
|
||||
choose(o) {
|
||||
if (this.choosing) return
|
||||
playSound('click')
|
||||
this.submit(o)
|
||||
},
|
||||
async submit(o) {
|
||||
this.choosing = true
|
||||
try {
|
||||
const data = await api.levelChoose(this.childId, this.levelId, this.curNode.node_id, o.option_id)
|
||||
this.routeSteps.push({ text: o.text, pros: o.feedback_pros || '' })
|
||||
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 后弹点评
|
||||
this.sceneFx = 'fx-shake'
|
||||
setTimeout(() => {
|
||||
this.sceneFx = ''
|
||||
this.feedback = {
|
||||
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.choosing = false
|
||||
}, 600)
|
||||
} 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
|
||||
})
|
||||
playSound('finish')
|
||||
uni.redirectTo({ url: '/pages/result/result' })
|
||||
} else {
|
||||
this.choosing = false
|
||||
}
|
||||
} catch (e) {
|
||||
this.choosing = false
|
||||
}
|
||||
},
|
||||
continuePlay() {
|
||||
this.curNode = this.feedback.next
|
||||
this.feedback = null
|
||||
this.mood = '🤔'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: template 部分改造**
|
||||
|
||||
在 `<view v-if="detail" class="content">` 之前插入开场演出覆盖层,并把选项列表块替换为 ActionCard:
|
||||
|
||||
开场覆盖层(插在 `</view>`(topbar 结束)之后、`<view v-if="detail" class="content">` 之前):
|
||||
|
||||
```html
|
||||
<!-- 开场演出(覆盖层,播完/跳过进入决策) -->
|
||||
<SceneTheater
|
||||
v-if="showIntro && detail"
|
||||
:scene-name="detail.scene ? detail.scene.name : ''"
|
||||
:scene-emoji="scene.emoji"
|
||||
:scene-color="scene.color"
|
||||
:scene-image="detail.scene_image"
|
||||
:content="detail.scene_content"
|
||||
:content-pinyin="detail.scene_content_pinyin"
|
||||
:audio="detail.scene_audio"
|
||||
:character="introCharacter"
|
||||
:props="introProps"
|
||||
:show-pinyin="showPinyin"
|
||||
@done="showIntro = false"
|
||||
/>
|
||||
```
|
||||
|
||||
场景卡片加演出状态类(`.scene-card` 增加 `:class="{ 'fx-shake': sceneFx }"`):
|
||||
|
||||
```html
|
||||
<view class="scene-card card" :class="{ 'fx-shake': sceneFx }" :style="{ background: scene.color + '44' }">
|
||||
```
|
||||
|
||||
互动分发整块替换(原「互动组件(触控 5 种)」v-if 链 + 「选项选择(type 1)」v-else-if 的 `class="options"` 列表 + 「soon 兜底」三段,替换为 activeInteraction 驱动):
|
||||
|
||||
```html
|
||||
<!-- 互动组件(触控 5 种,config 互动节点) -->
|
||||
<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>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: style 部分追加**
|
||||
|
||||
在 `<style scoped>` 末尾(`.fb-continue` 规则后)追加:
|
||||
|
||||
```css
|
||||
.fx-shake { animation: fx-shake 0.5s ease; }
|
||||
@keyframes fx-shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-12rpx); } 50% { transform: translateX(10rpx); } 75% { transform: translateX(-6rpx); } }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: H5 编译检查**
|
||||
|
||||
Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/pages/play/play.vue` 返回 200。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add ui-src/src/pages/play/play.vue
|
||||
git commit -m "feat: 闯关页剧场化(开场演出覆盖层+动作卡呈现+分支演出晃动过渡)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `result.vue` 结局演出(彩带 + 动态标题 + 鼓励语)
|
||||
|
||||
**Files:**
|
||||
- Modify: `ui-src/src/pages/result/result.vue`
|
||||
|
||||
- [ ] **Step 1: script 部分改造**
|
||||
|
||||
`<script>` 整体替换为:
|
||||
|
||||
```vue
|
||||
<script>
|
||||
import { sceneVisual } from '../../utils/visual.js'
|
||||
import { playSound } from '../../utils/sound.js'
|
||||
|
||||
const RESULT_KEY = '36wisdom_result'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return { result: null, pieces: [] }
|
||||
},
|
||||
onLoad() {
|
||||
const saved = uni.getStorageSync(RESULT_KEY)
|
||||
this.result = saved || null
|
||||
if (!saved) {
|
||||
uni.reLaunch({ url: '/pages/map/map' })
|
||||
return
|
||||
}
|
||||
this.pieces = this.makePieces(30)
|
||||
playSound(this.result.final.stars > 0 ? 'finish' : 'bad')
|
||||
},
|
||||
computed: {
|
||||
stars() {
|
||||
return this.result.final.stars
|
||||
},
|
||||
title() {
|
||||
if (this.stars >= 3) return '完美通关!'
|
||||
if (this.stars > 0) return '通关啦!'
|
||||
return '再试一次!'
|
||||
},
|
||||
sceneEmoji() {
|
||||
return sceneVisual(this.result.scene_name).emoji
|
||||
},
|
||||
encourage() {
|
||||
if (this.stars === 0) return '你已经很棒了,再试一次会更好!'
|
||||
if (this.stars < 3) return '还差一点点,补完分支就能完美!'
|
||||
return ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
replay() {
|
||||
uni.redirectTo({ url: '/pages/play/play?level_id=' + this.result.level_id })
|
||||
},
|
||||
backMap() {
|
||||
uni.reLaunch({ url: '/pages/map/map' })
|
||||
},
|
||||
makePieces(n) {
|
||||
const colors = ['#ff6b35', '#ffb347', '#4ecdc4', '#6c8cff', '#a58cff', '#ff6b9a']
|
||||
const list = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
list.push({
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 1.5,
|
||||
duration: 2 + Math.random() * 2,
|
||||
color: colors[i % colors.length],
|
||||
rotate: Math.random() * 360
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: template 部分改造**
|
||||
|
||||
`<template>` 整体替换为:
|
||||
|
||||
```html
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 彩带(通关/完美时) -->
|
||||
<view v-if="result && stars > 0" class="confetti">
|
||||
<view
|
||||
v-for="(p, i) in pieces"
|
||||
:key="i"
|
||||
class="confetti-piece"
|
||||
:style="{ left: p.left + '%', background: p.color, animationDelay: p.delay + 's', animationDuration: p.duration + 's', transform: 'rotate(' + p.rotate + 'deg)' }"
|
||||
></view>
|
||||
</view>
|
||||
|
||||
<view v-if="result" class="result">
|
||||
<view class="result-emoji">{{ sceneEmoji }}</view>
|
||||
<view class="result-title" :class="{ great: stars >= 3, ok: stars > 0 && stars < 3, again: stars === 0 }">{{ title }}</view>
|
||||
|
||||
<view class="stars-area">
|
||||
<view
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="big-star"
|
||||
:class="{ on: i <= stars, pop: i <= stars }"
|
||||
:style="{ animationDelay: i * 0.35 + 's' }"
|
||||
>★</view>
|
||||
</view>
|
||||
|
||||
<view v-if="encourage" class="encourage">💪 {{ encourage }}</view>
|
||||
|
||||
<view class="card panel">
|
||||
<view class="panel-row">
|
||||
<text class="label">关卡</text>
|
||||
<text class="value">{{ result.title }}</text>
|
||||
</view>
|
||||
<view class="panel-row">
|
||||
<text class="label">积分</text>
|
||||
<text class="value delta" :class="result.final.score_delta >= 0 ? 'up' : 'down'">
|
||||
{{ result.final.score_delta >= 0 ? '+' : '' }}{{ result.final.score_delta }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="panel-row">
|
||||
<text class="label">当前余额</text>
|
||||
<text class="value">{{ result.final.balance_after }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="result.final.perfect" class="badge perfect">✨ 完美通关!</view>
|
||||
<view v-if="result.final.collection_unlocked" class="badge card-collect">🃏 获得计策卡</view>
|
||||
<view v-if="result.final.unlock_next" class="badge next-unlock">🔓 下一计已解锁</view>
|
||||
<view v-if="result.final.new_level > 0" class="badge level-up">🎉 成长等级提升!</view>
|
||||
|
||||
<view class="actions">
|
||||
<view class="btn-primary action" @click="replay">再玩一次</view>
|
||||
<view class="btn-primary action ghost" @click="backMap">返回地图</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: style 部分追加**
|
||||
|
||||
`<style scoped>` 末尾追加(保留原有所有规则):
|
||||
|
||||
```css
|
||||
.result-emoji { font-size: 96rpx; margin-bottom: 8rpx; animation: t-pop 0.6s ease; }
|
||||
.result-title.great { color: #ffb347; }
|
||||
.result-title.ok { color: #ff6b35; }
|
||||
.result-title.again { color: #6c8cff; }
|
||||
.encourage { font-size: 30rpx; color: #7a6248; background: #fff0e5; border-radius: 30rpx; padding: 16rpx 32rpx; margin-bottom: 32rpx; display: inline-block; }
|
||||
@keyframes t-pop { 0% { opacity: 0; transform: scale(0.5); } 70% { transform: scale(1.1); } 100% { opacity: 1; transform: scale(1); } }
|
||||
.confetti { position: fixed; inset: 0; pointer-events: none; overflow: hidden; z-index: 5; }
|
||||
.confetti-piece { position: absolute; top: -40rpx; width: 16rpx; height: 28rpx; border-radius: 4rpx; opacity: 0; animation: confetti-fall linear forwards; }
|
||||
@keyframes confetti-fall { 0% { opacity: 1; transform: translateY(0) rotate(0); } 100% { opacity: 0.7; transform: translateY(110vh) rotate(720deg); } }
|
||||
```
|
||||
|
||||
> 注意:`@keyframes t-pop` 已在 Task 3 的 SceneTheater 内定义(scoped 隔离,互不冲突);若本文件重复定义同名 keyframes 无碍(各文件 scoped)。
|
||||
|
||||
- [ ] **Step 4: H5 编译检查**
|
||||
|
||||
Run: `curl -s -o /dev/null -w "%{http_code}" http://localhost:5173/src/pages/result/result.vue` 返回 200。
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add ui-src/src/pages/result/result.vue
|
||||
git commit -m "feat: 结算页结局演出(彩带+动态标题+鼓励语+场景 emoji)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 文档更新 + 端到端走查 + 提交
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`(情境闯关行)
|
||||
- Modify: `技术设计.md`(4.13 游戏化 UI 设计补充三幕剧场化)
|
||||
|
||||
- [ ] **Step 1: README「情境闯关」行补充**
|
||||
|
||||
`README.md` 功能模块表中「情境闯关」行末尾追加(保持现有行文风格):
|
||||
|
||||
```
|
||||
呈现为三幕小剧场:开场演出(旁白朗读+字幕逐字淡入+角色入场+道具飞入,可跳过)→ 决策演出(普通节点为动作卡——图标/拼音/听读/按压动画,config 互动节点为触控互动,选择后分支演出过渡再弹对比点评)→ 结局演出(成功彩带/失败鼓励语)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 技术设计.md 4.13 补充**
|
||||
|
||||
`技术设计.md` 的「4.13 游戏化 UI 设计」小节末尾追加「三幕剧场化」说明(含:开场演出覆盖层 SceneTheater、动作卡 ActionCard、分支演出 0.6s 晃动过渡、结局彩带 CSS 粒子;互动形态判定:config 互动节点用触控组件,普通决策节点一律动作卡——理由:触控组件 success/fail 双出口与多分支选项语义不兼容)。
|
||||
|
||||
- [ ] **Step 3: 后端回归 + 全量编译**
|
||||
|
||||
Run: `go build ./... && go vet ./... && go test ./...` 全部通过(无后端改动,确认无回归)。
|
||||
|
||||
- [ ] **Step 4: H5 走查(浏览器人工)**
|
||||
|
||||
Run: `curl -s http://localhost:5173/` 200(dev server 已在跑)。浏览器打开 http://localhost:5173 走查:
|
||||
|
||||
1. 进入任意关卡 → 开场演出自动播放(旁白朗读、字幕逐字淡入、角色滑入、道具飞入),点「跳过」立即进入
|
||||
2. 决策节点显示动作卡(图标/拼音/听读/四色边框),点击有按压动画
|
||||
3. 选择后场景晃动 0.6s 再弹点评(你的选择 + 好处/坏处)
|
||||
4. 走完分支到结算 → 通关显示彩带 + 庆祝标题;故意走错到未通关 → 鼓励语
|
||||
5. 拼音开关、声音开关、进度点正常
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add README.md 技术设计.md
|
||||
git commit -m "docs: 三幕剧场化设计(README 情境闯关行 + 技术设计 4.13 补充)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 计划外可选(本次不实施)
|
||||
|
||||
- 36 关入口互动数据补全(种子补道具选项):触控互动覆盖面由后台配置扩展,非本计划范围
|
||||
- genasset 角色表情帧/动作帧素材(阶段 C):依赖 Task 10 素材管线完成后另行计划
|
||||
Reference in New Issue
Block a user