Appearance
ADR-207: 动作菜单重构 —— 程序化动作可加载化 + 双面板对称
- 状态: ✅ 已完成(Phase 1-3 全部落地)
- 日期: 2026-07-30
- 相关: ADR-167(场景级动作库)、ADR-170(选中范式)、ADR-169(装载语义统一)、ADR-121(双槽位动作分配)
- 源码锚点:
menus/motion-root-ui.ts:buildMotionRootItems、menus/model-detail.ts:buildMotionSlotLevel、core/types.ts:MotionSlotConfig.procRole、scene/motion/proc-motion-bridge.ts:ProcMotionController
背景
当前动作菜单(buildMotionRootItems)存在三个结构性问题:
问题 1:「无动作」是特殊状态
sceneMotions.length === 0 时根菜单渲染一个硬编码的提示行(target: '__motion_detail__:'),点击后走 motion:open-detail 但参数为空导致执行器拒绝(缺少必要参数: sceneMotionId,已临时修复 optional)。概念上「无动作」被当作异常状态而非合法选择。
问题 2:程序化动作是硬编码文件夹
相机控制、姿势工作室、注视追踪作为 kind: 'folder' 行硬编码在根菜单底部,用户无法增减。它们与 VMD 动作列表混排在同一层级,用户进来不知道这些文件夹和上面的 VMD 列表是什么关系。
问题 3:角色面板动作 slot 过重
buildMotionSlotLevel 在一个页面里同时承担 VMD 选择、程序化切换、已加载回退三层信息,与动作菜单职责重叠。
核心洞察
对比角色菜单(buildMotionSlotLevel in model-detail.ts)的设计——以角色为主体、状态可见、分区清晰——发现动作菜单缺的不是功能,而是统一的分层模型:
「无动作」不是异常,是程序化动作的一种。程序化动作不是硬编码文件夹,是可加载的实体。
设计决策
0. 术语澄清(源码现状→目标模型)
现状下“程序化”一词被两类不同东西共用,必须先分开,否则 Phase 3 会把不同生命周期的对象强行合并:
| 现状实体 | 源码形态 | 状态载体 | 本 ADR 归类 |
|---|---|---|---|
| 待机呼吸 / 自动舞蹈 | procRole: 'idle' | 'autodance' | 'gesture' | 'expression',附于 MotionSlotConfig.primary | per-model procMotion 状态(ProcMotionController) | 程序化动作(可加载实体) |
| 相机控制 / 姿势工作室 / 注视追踪 | 根菜单独立 kind: 'folder'(motion:camera/poseStudio/gaze) | 各自独立子系统,不占 primary slot | 场景工具(非动作,不入双区) |
关键区分:前者与 VMD 动作互斥(占用同一 primary slot,一时只能激活一个),故归入“已加载程序化动作”区;后者与动作正交(可与任意动作共存),保留在“更多”区。
先前草案把“注视追踪 / 姿势 / 相机”当作程序化动作的一种(见“动作详情页 · 模式”),本次修正:它们是正交工具,不进“已加载程序化动作”区。“模式”子区仅对应
procRole四值。
1. 动作分类模型
所有动作归为两类,始终分区显示:
| 分区 | 含义 | 来源 | 示例 |
|---|---|---|---|
| 已加载动作 | VMD 文件驱动的动作 | 从动作库浏览加载 | 跳舞.vmd、走路.vmd |
| 已加载程序化动作 | 代码驱动的动作 | 从程序化动作子页加载 | 无动作、待机呼吸、自动舞蹈 |
两区始终出现,不因列表为空而隐藏。空区显示空提示行。
2. 程序化动作可加载化
程序化动作从硬编码文件夹变为可加载实体:
程序化动作子页(从「动作库」区进入)
┌─────────────────────────────────┐
│ ✨ 待机呼吸 + │ ← 点击加载到「已加载程序化动作」
│ ✨ 自动舞蹈 + │
│ ✨ 注视追踪 + │
│ ✨ 姿势工作室 + │
│ ✨ 相机控制 + │
└─────────────────────────────────┘「无动作」始终自动加载,不可卸载——它是系统的保底状态。
3. 双面板职责分离
| 面板 | 职责 | 类比 |
|---|---|---|
| 动作菜单(场景级) | 管理「有什么可用」:加载/卸载动作 | 应用商店 |
| 角色面板 · 动作 slot(角色级) | 管理「这个角色用哪个」:选择/切换 | 已安装应用列表 |
预期 UI
动作菜单(场景级)
┌─────────────────────────────────────┐
│ ── 已加载动作 ── │
│ 🎬 跳舞.vmd ⚙ │ ← 行点击→详情,⚙→工具(删除/设为默认)
│ 🎬 走路.vmd ⚙ │
├─────────────────────────────────────┤
│ ── 已加载程序化动作 ── │
│ ✨ 无动作 ⚙ │ ← 始终存在,不可卸载(建模见“待定决策”)
│ ✨ 待机呼吸 ⚙ │
│ ✨ 自动舞蹈 ⚙ │
├─────────────────────────────────────┤
│ ── 动作库 ── │
│ 📂 浏览动作文件 │ ← 加载 VMD 到「已加载动作」
│ 🎵 背景音乐 │
│ ✨ 程序化动作 │ ← 进子页,加载到「已加载程序化动作」
├─────────────────────────────────────┤
│ ── 更多 ── │
│ 📷 姿势工作室 │
│ 🎥 相机控制 │
│ 📤 外部导入 │
└─────────────────────────────────────┘角色面板 · 动作 slot(角色级)
┌─────────────────────────────────────┐
│ ── 已加载动作 ── │
│ 🎬 跳舞.vmd 使用中 默认 │ ← 行点击→选中(◉);行尾 `使用中`+`默认` 徽标
│ ○ 跟随默认 │ ← = 选中带“默认”徽标那行,非独立选项
├─────────────────────────────────────┤
│ ── 已加载程序化动作 ── │
│ ✨ 无动作 使用中 │ ← 与上区共用同一套 ◉/○ 选中语义
│ ✨ 待机呼吸 │ ← 点击→激活
│ ✨ 自动舞蹈 │
└─────────────────────────────────────┘两区始终出现,与动作菜单对称,且跨两区共用同一套选中语义(见下“徽标语义统一”)。
徽标语义统一(消除“当前/激活中/默认”分裂)
改造后程序化动作也能被选为默认,原来用于区分 VMD/程序化的“当前”(VMD 区)与“激活中”(程序化区)两词已失去区分意义——它们本质都是“本角色 primary slot 现在指向谁”。统一为两个正交维度:
| 维度 | 含义 | 作用域 | UI 表现 |
|---|---|---|---|
| 选中(使用中) | 本角色的 primary slot 当前指向哪行 | 角色级(每角色一个) | 行首 ◉(选中)/ ○(未选),可选行尾 使用中 徽标 |
| 默认 | 该行是否 = 场景默认动作(activeMotionId) | 场景级(全局唯一) | 行尾 默认 徽标 |
关键变化:
- 废除“当前”与“激活中”两词,两区统一用“选中语义”(行首
◉/○,对齐 ADR-170 选中范式)。一个角色同时只能选中一行(跨两区互斥,因为都占 primary slot)。 - “跟随默认”不再是独立行:角色未显式选择时(
source:'inherit'无 sceneMotionId),◉自动落在带默认徽标的那行——“跟随默认”= 选中默认行,无需额外选项。若仍需显式“取消本角色覆盖”入口,收进行尾工具而非占一行。 使用中与默认可共存:角色选了场景默认动作时,同一行同时显使用中(选中)+默认(场景),两枚徽标不矛盾。- 动作菜单(场景级)同步:现根菜单 VMD 行已用
check-circle表“选中=场景默认”(ADR-170);程序化区同样采用该语义,使“无动作/待机呼吸”也能被设为场景默认。
动作详情页(统一)
不论动作来源(VMD / 程序化 / 无动作),详情页结构一致:
┌─────────────────────────────────────┐
│ ── 当前动作 ── │
│ 🎬 跳舞.vmd │ ← 程序化时显示程序化名
├─────────────────────────────────────┤
│ ── 图层 ── │ ← 仅 kind: vmd 且有图层时
│ 全身 │
├─────────────────────────────────────┤
│ ── 模式 ── │ ← 仅 kind: procedural 时
│ [注视] [姿势] [相机] │
├─────────────────────────────────────┤
│ ── 骨骼覆盖 ── │ ← 始终出现
│ 左手调整 │
│ 右手调整 │
├─────────────────────────────────────┤
│ ── 预设 ── │ ← 始终出现
├─────────────────────────────────────┤
│ ── 播放速度 ── │
│ ═══●════ 1.0x │
└─────────────────────────────────────┘实施阶段
Phase 1: 动作菜单根重构
修改 buildMotionRootItems()(menus/motion-root-ui.ts),保持现有行行为不变,只调分区与消除空分支:
- “已加载动作”区(sceneMotions):首行插入
addSectionTitle等价的 divider+标题行(keymotion.section.loadedMotion);删除sceneMotions.length === 0分支,空时改为渲染一个 disabled 空提示行(keymotion.section.loadedMotionEmpty,不再用target: '__motion_detail__:'的伪行)。非空时保留现有 leading选中/trailing工具 逻辑。 - “已加载程序化动作”区(key
motion.section.loadedProc):Phase 1 先硬编码两行——待机呼吸 (idle)、自动舞蹈 (autodance),行尾⚙进入对应buildProcMotionLevel。“无动作”不在此区(无动作不需加载,它等价于 sceneMotions 为空 + 无 procRole);Phase 3 再改为集合驱动。 - “动作库”区(Card 2):保留
浏览动作文件+背景音乐;Phase 3 在此新增程序化动作子页入口。 - “更多”区(Card 3):保留
相机/姿势/注视/外部导入四个 folder 不动(它们是正交工具)。
验收:去掉的 motion.noMotionHint / __motion_detail__: 空参路径同时检查 motion-intent执行器的 optional 临时修复是否可回收(背景问题 1)。运行 frontend 下 npm run test,重点回归依赖空列表 UI 的用例(风险表第 3 项)。
Phase 2: 角色面板对称
修改 buildMotionSlotLevel()(menus/model-detail.ts:412),两区始终出现,与动作菜单镜像:
- “已加载动作”区:把现有“动作库”标题(
motion.library.title)改为motion.section.loadedMotion;“跟随默认”行保留作为区内首行(= 取消选择/source:'inherit'无 sceneMotionId)。删除 476–523 行那块条件展示的“已加载动作”回退块(其职责已并入本区,不再仅在 proc/pinned 时出现)。sceneMotions 为空时保留现有addEmptyRow(motion.library.emptyHint),但区标题常驻。 - “已加载程序化动作”区:将现有“程序化动作”标题(
model-detail.procActions)改为motion.section.loadedProc;保留待机呼吸/自动舞蹈两行及其procRole切换 +procEditchip 逻辑不变(风险表第 2 项:保留 pinned/inherit/procedural 分支)。 - 两区标题与动作菜单复用同一组 i18n key,保证“应用商店 vs 已安装列表”的镜像感。
验收:手动验证四种状态下面板渲染正确:(a)无动作(b)仅 inherit(c)pinned(d)procedural 激活;参照前文“角色面板 · 动作 slot”预期 UI。
Phase 3: 程序化动作可加载化(后续)
将程序化动作从硬编码模式切换变为可加载/卸载的实体。需状态管理变更(影响面大,独立上线):
- 新增场景级
loadedProceduralMotions: Set<ProcMotionMode>状态(建议放scene/motion/motion-intent,与 sceneMotions 同层序列化)。 - 动作菜单新增“程序化动作”子页(
motion:proc-library),列出全部ProcMotionMode,+加入集合 / trailing 卸载;只有集合内的模式才出现在双区“已加载程序化动作”。 - 角色面板只从集合中选择激活,不再硬编码 idle/autodance 两行。
procRole四值(idle/autodance/gesture/expression)全量纳入可加载集合(当前 UI 仅暴露前两个)。- “无动作”的建模:已定为方案 B(真实实体,
ProcMotionMode: 'none'),见下“关键决策:‘无动作’建模”。
关键决策:“无动作”建模
“无动作”的归属与建模 —— 已定:方案 B(真实实体)。引入 ProcMotionMode: 'none',始终在 loadedProceduralMotions 集合中且不可卸载,作为系统保底状态。这使“一切动作都是可加载实体”的模型完全统一。落地要点:
- 在
motion-algos/procedural-motion的ProcMotionMode枚举新增'none';对应procRole也需容纳'none'(或约定source:'procedural' + procRole:'none'等价于“无动作”)。 - 与现有隐式空态区分:当前“无动作”= sceneMotions 为空 + 无 procedural;方案 B 下统一为显式
procRole:'none',避免隐式分支。需确保_setProcForModel(id, inst, 'none')与stopProcMotion行为一致。 - Phase 1-2 可先以隐式空态占位;Phase 3 引入
'none'枚举后,将双区首行接入真实实体。
集合初始值:loadedProceduralMotions = new Set(['none']),'none' 不提供卸载 trailing。
Phase 3’: “更多”区去重(可选)
若 Phase 3 的“程序化动作”子页已统一管理入口,评估是否从“更多”区移除重复项——但注意相机/姿势/注视是正交工具,不应被归入程序化动作子页;仅当未来存在真正重复的入口时才去重。
风险与约束
| 风险 | 缓解 |
|---|---|
| 程序化动作状态管理变更影响面大 | Phase 1-2 先用现有 procmotion 状态,Phase 3 再抽象 |
角色面板 buildMotionSlotLevel 已有复杂的 pinned/inherit/procedural 分支 | Phase 2 保持现有分支逻辑,只改 UI 分区 |
测试路径依赖 sceneMotions.length === 0 的特殊 UI | 确保空列表时“已加载动作”区的 disabled 空提示行可达;回归依赖旧 __motion_detail__: 空参行的用例 |
| “更多”区的姿势/相机/注视被误归为程序化动作 | 已在“术语澄清”明确它们是正交工具,留在“更多”区;Phase 3’ 去重仅针对真重复入口 |
“无动作”建模已定为方案 B('none' 枚举) | Phase 1-2 仍用隐式空态不阻塞;Phase 3 新增 'none' 时需同步 procRole 类型与 _setProcForModel/stopProcMotion 行为 |
| 两区选中态必须互斥(都占 primary slot) | 选中程序化行时需清除 VMD 区的 ◉,反之亦然;单一 source 字段已天然保证互斥,UI 只需据 source+sceneMotionId/procRole 判定选中行 |