Skip to content

ADR-207: 动作菜单重构 —— 程序化动作可加载化 + 双面板对称

  • 状态: ✅ 已完成(Phase 1-3 全部落地)
  • 日期: 2026-07-30
  • 相关: ADR-167(场景级动作库)、ADR-170(选中范式)、ADR-169(装载语义统一)、ADR-121(双槽位动作分配)
  • 源码锚点: menus/motion-root-ui.ts:buildMotionRootItemsmenus/model-detail.ts:buildMotionSlotLevelcore/types.ts:MotionSlotConfig.procRolescene/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.primaryper-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),保持现有行行为不变,只调分区与消除空分支:

  1. “已加载动作”区(sceneMotions):首行插入 addSectionTitle 等价的 divider+标题行(key motion.section.loadedMotion);删除 sceneMotions.length === 0 分支,空时改为渲染一个 disabled 空提示行(key motion.section.loadedMotionEmpty,不再用 target: '__motion_detail__:' 的伪行)。非空时保留现有 leading选中/trailing工具 逻辑。
  2. “已加载程序化动作”区(key motion.section.loadedProc):Phase 1 先硬编码两行——待机呼吸 (idle)、自动舞蹈 (autodance),行尾 进入对应 buildProcMotionLevel“无动作”不在此区(无动作不需加载,它等价于 sceneMotions 为空 + 无 procRole);Phase 3 再改为集合驱动。
  3. “动作库”区(Card 2):保留 浏览动作文件 + 背景音乐;Phase 3 在此新增 程序化动作 子页入口。
  4. “更多”区(Card 3):保留 相机/姿势/注视/外部导入四个 folder 不动(它们是正交工具)。

验收:去掉的 motion.noMotionHint / __motion_detail__: 空参路径同时检查 motion-intent执行器的 optional 临时修复是否可回收(背景问题 1)。运行 frontendnpm run test,重点回归依赖空列表 UI 的用例(风险表第 3 项)。

Phase 2: 角色面板对称

修改 buildMotionSlotLevel()menus/model-detail.ts:412),两区始终出现,与动作菜单镜像:

  1. “已加载动作”区:把现有“动作库”标题(motion.library.title)改为 motion.section.loadedMotion;“跟随默认”行保留作为区内首行(= 取消选择/source:'inherit' 无 sceneMotionId)。删除 476–523 行那块条件展示的“已加载动作”回退块(其职责已并入本区,不再仅在 proc/pinned 时出现)。sceneMotions 为空时保留现有 addEmptyRow(motion.library.emptyHint),但区标题常驻。
  2. “已加载程序化动作”区:将现有“程序化动作”标题(model-detail.procActions)改为 motion.section.loadedProc;保留待机呼吸/自动舞蹈两行及其 procRole 切换 + procEdit chip 逻辑不变(风险表第 2 项:保留 pinned/inherit/procedural 分支)。
  3. 两区标题与动作菜单复用同一组 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-motionProcMotionMode 枚举新增 '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 判定选中行