Skip to content

ADR-121: 全局动作意图(Scene-level Motion Intent)— 场景级意图 + 每实例继承/覆盖

状态: 已实施(P0+P1+P2 已完成,2026-07-18) 日期: 2026-07-17 路径约定: 本文档源码路径均省略 frontend/src/ 前缀(与 ADR-120 / ADR-116 一致),例如 core/types.ts = frontend/src/core/types.tscore/i18n/locales/ = frontend/src/core/i18n/locales/

背景与问题

当前动作是纯每实例(per-ModelInstance 的:ModelInstance 持有 vmdData / vmdName / vmdPath / animationDuration / vmdLayerscore/types.ts:100-105),动作菜单(menus/motion-popup.ts)把 VMD 写入聚焦模型inst.vmdData

这带来两个体验与架构问题:

  1. 换角色必须重选动作:用户只想看角色、反复换皮时,每次换模型都要重新在菜单里点一遍同一个动作。对「欣赏型」用户是纯负担。
  2. 动作菜单职责错位:菜单本质是「让角色动起来」,却被迫承载「给哪个角色上什么」的派发逻辑,与「场上在跳什么」的直觉相悖。

已有的雏形(应被正式化,而非另造)

scene/manager/model-loader.ts:457 存在 pendingVmd:模型加载时若有待定 VMD 则自动套用。这正是「全局意图在加载时广播」的胚胎实现——但它只服务于「切换时带上一个 pending」,无场景级意图、无继承/覆盖语义、无兼容性判定。本 ADR 将其升级为场景级 activeMotion 意图 + 每实例继承/覆盖

关联资产(证明可行性,非从零开始)

资产对本 ADR 的支撑
ModelInstance + Map 注册表(core/types.ts天然支持「场景意图 → 遍历实例套用」
共享 WASM 物理时间轴全局播放时间一致,多角色天然同步无漂移
ADR-108 AnimationRetargeter(已落地)可选重映射手段:提供 babylon-mmdAnimationRetargeter 工具类,用于 Mixamo/VRM 等外部动画源导入时做骨骼名重映射。animation-retargeter.ts 本身是 UI 驱动的单次导入流程getBoneMapPresetsplayRetargetedAnimation),广播期「逐模型取兼容 VMD 子集」的引擎
motion-algos/proc-motion-shared.ts:146 matchBone兼容性骨骼名匹配的真正来源matchBone(actualBones, candidates) 实现全角/半角/英文变体候选表匹配(工程铁律「MMD 骨骼 IK 全角」),resolve() 应复用之
ADR-116 动作覆盖模块(ModelInstance.motionOverrideModules,per-model)覆盖模块是基础 VMD 之上的独立层(管线第⑤层),全局意图只改「基础 VMD 来源」,与覆盖层正交,无冲突
model-loader.ts:457 pendingVmd加载时套用 VMD 的现有钩子,本 ADR 复用其调用点

决策:场景级意图 + 每实例继承/覆盖(混合体)

核心区分:「全局生效」≠「所有角色盲播同一条 VMD」。VMD 按骨骼名引用,模型骨骼结构不同 → 广播时各模型只取自己兼容的子集(MMD 原生行为)。因此落地为:

  • 场景级 activeMotion:场上当前意图(「在跳什么」)。none 表示静态欣赏。
  • 每实例 motionAssignmentmode: 'inherit' | 'pinned'。默认 inherit → 继承 activeMotionpinned → 独立指定(合奏/对舞场景用)。
  • 加载即继承:新模型加载时 mode='inherit',按 activeMotion 解析兼容性后套用。换角色无需重选。
  • 覆盖解耦:右键模型可 pin 指定动作 / unpin(跟随全局)。仅差异化场景才需手动分配。

关键不变量

  1. vmdData/vmdName/vmdPath/vmdLayers 仍是 ModelInstance 的「已解析缓存」,playback / vmd-loader / vmd-layers 内部继续读这些字段,无需改动。本 ADR 只改变「谁、何时写入这些字段」——从「菜单写聚焦模型」改为「广播策略按 assignment 写入」。
  2. activeMotion 不入 EnvState。动作是场景内容而非视觉环境;归入场景文件 + 一个轻量场景级 TS store(非 Go EnvState struct),规避 EnvState 持久化须同步 Go struct + 重生成 wails 绑定的工程铁律成本(见 MEMORY.md 工程铁律「EnvState 持久化」)。
  3. none 是一等公民:全局意图为 none 时所有 inherit 模型保持静态,满足「只想看角色」的用户。

数据模型

ts
// core/types.ts 或 scene/motion/motion-intent.ts
/**
 * 用户选择的「原始动作来源类型」——仅描述意图来源性质,不描述广播后的运行时产物。
 * - 'vmd'      :MMD 库动作(标准 VMD,直接按骨骼名匹配,走 matchBone)
 * - 'retargeted':外部动画经 ADR-108 AnimationRetargeter 重映射后的来源(Mixamo/VRM 等)
 * 注:每模型 resolve 后得到的仍是 VMD / 基于 VMD 的 AnimationGroup,
 *      故本字段是「来源性质」的可选元信息,非运行时类型分派。
 */
export type MotionSource = 'vmd' | 'retargeted'; // 可选:仅描述 activeMotion 来源性质

/** 场景级动作意图(「场上在跳什么」) */
export interface SceneMotionIntent {
  vmdPath: string | null;     // 库引用或绝对路径(持久化用)
  vmdName: string;
  vmdLayers: VmdLayer[];
  source: MotionSource;
  // vmdData 为运行时缓存,不持久化
}

/** 每实例动作分配策略 */
export interface ModelMotionAssignment {
  mode: 'inherit' | 'pinned';
  pinned?: SceneMotionIntent;             // mode==='pinned' 时有效;须 structuredClone(activeMotion) 冻结快照,避免改全局污染已 pin 实例
  status: 'compatible' | 'incompatible' | 'idle' | 'overridden';
}

// ModelInstance 扩展(保留现有 vmd* 缓存字段不动)
export type ModelInstance = {
  // ...existing vmdData/vmdName/vmdPath/animationDuration/vmdLayers...
  motionAssignment?: ModelMotionAssignment; // [doc:adr-121] 默认 undefined = 视为 inherit+idle
};

场景级 store(轻量 singleton,非 EnvState)

ts
// scene/motion/motion-intent.ts
let _activeMotion: SceneMotionIntent | null = null; // null = none(静态)
export function getActiveMotion(): SceneMotionIntent | null;
export function setActiveMotion(intent: SceneMotionIntent | null): void; // 触发 broadcastMotion()
export function broadcastMotion(): void; // 遍历 modelMap,按 assignment 解析+写入 inst.vmd*

数据流(与现状一致,仅策略层变化)

用户从动作菜单选择动作
  └─ setActiveMotion(intent)          // 场景级意图
       └─ broadcastMotion()
            ├─ 遍历 modelMap 每个 inst
            │    ├─ mode==='inherit' → resolve(activeMotion, inst.skeleton)
            │    └─ mode==='pinned'  → resolve(inst.assignedMotion.pinned, inst.skeleton)
            │         ├─ 兼容 → 写 inst.vmdData/vmdName/vmdPath/vmdLayers + status='compatible'
            │         └─ 不兼容 → 不动 inst.vmd*(保留 idle/已有),status='incompatible'
            └─ 触发 playback 重载(复用现有 loadVMDMotion / vmd-layers 链路)

新模型加载(model-loader.ts:457 调用点)
  └─ inst.motionAssignment = { mode:'inherit', status:'idle' }
       └─ resolve(activeMotion, inst.skeleton) → 兼容则立即套用

用户右键模型 → pin 指定动作
  └─ inst.motionAssignment = { mode:'pinned', pinned: intent, status:'overridden' }
       └─ resolve(pinned, inst.skeleton) → 写 inst.vmd*

用户右键模型 → unpin
  └─ inst.motionAssignment = { mode:'inherit' }
       └─ resolve(activeMotion, inst.skeleton) → 回归全局

兼容性解析(resolve() 为待新建函数,非复用既有引擎)

  • 骨骼名匹配复用 motion-algos/proc-motion-shared.ts:146matchBone(actualBones, candidates),其内部候选表覆盖全角 左足IK/半角/英文变体(工程铁律「MMD 骨骼 IK 全角」)。
  • ADR-108 的 AnimationRetargeterbabylon-mmd 工具类)仅作可选重映射手段:当 activeMotion.source==='retargeted'(外部 Mixamo/VRM 动画)时,对单目标模型做一次性骨骼重映射;常规库 VMD 广播不依赖 retargeter。
  • resolve() 返回「该模型可套用的 VMD 子集 / 重定向后 AnimationGroup + 兼容骨骼清单」,并据此置 status

与现有系统的边界

系统关系处置
playback.ts / vmd-loader.ts / vmd-layers.ts仍读 inst.vmd* 缓存不变(仅写入方改变)
ADR-116 动作覆盖模块(per-model)基础 VMD 之上的独立层不变;全局意图只改基础来源,覆盖模块照常叠加
ADR-108 retargeter可选重映射手段(外部动画源),非广播期兼容性引擎复用 AnimationRetargeter 工具类作可选重映射;常规库 VMD 广播走 proc-motion-shared.matchBone,不重复建设
model-loader.ts:457 pendingVmd加载时套用钩子吸收inherit 解析逻辑,移除零散 pending 变量
scene-serialize.ts场景持久化扩展(见下)
ADR-119 缩略图 cache key场景恢复传参一致性scene-restore 分支须一并传入 motionAssignment,对齐 buildThumbnailKey 契约

持久化扩展(scene-serialize.ts)

字段位置说明
motion.activeMotion场景文件顶层(新增 motion 块)场景级意图;null=none
inst.motionAssignment每实例mode + pinned(如 pinned);status 为运行时派生、不落盘
inst.vmdPath/vmdName/vmdLayers每实例(已有)作为 inherit 模型的可重建缓存:加载时若 activeMotion 存在则优先从意图重解析,否则回退到该缓存(保持旧场景文件兼容)

向后兼容:旧场景文件无 motion 块 → 加载时 activeMotion=null,各模型按已有 vmdPath 缓存还原(与当前行为一致),不报错。


实施分期

阶段文件操作验收
P0scene/motion/motion-intent.ts(新增)定义 SceneMotionIntent/ModelMotionAssignment + 场景级 store get/setActiveMotion/broadcastMotion/resolve 骨架tsc 通过;setActiveMotion 触发 broadcastMotion 遍历 modelMap
P0core/types.tsModelInstancemotionAssignment?现有测试不破
P1menus/motion-popup.ts菜单操作改为 setActiveMotion(场景级),不再写聚焦模型 inst.vmdData选动作后所有 inherit 兼容模型同步起舞
P1scene/manager/model-loader.ts:456-458 + core/state.ts:108pendingVmd 定义)pendingVmd 钩子改为 inst.motionAssignment={mode:'inherit'} + resolve(activeMotion)迁移所有下游引用点scene.ts:489(场景入口传参)、__tests__/model-ops.test.ts:568clears pendingVmd 断言)等,否则移除会破测试与场景入口新模型加载即继承全局动作;全仓无残留 pendingVmd 引用
P1UI(动作菜单 + 模型右键)菜单标题语义改为「场上在跳什么」;模型右键 pin/unpinincompatible 状态显式提示(「此角色不兼容当前动作」)不兼容模型不被静默无视;静态场景 none 正确保持
P2scene/scene-serialize.tsmotion.activeMotion + 每实例 motionAssignment;加载还原 + 重解析(回退旧缓存)保存→重载后全局动作与 per-model 覆盖一致还原
P2i18n(5 语言)新增 motion.intent.* key(见 §i18n)日/英/韩/繁体/简体齐全

风险与缓解

级别风险缓解
🔴 P1不兼容模型被全局动作「静默无视」→ 用户以为坏了status='incompatible' 显式标记 + UI 提示;绝不覆盖模型已有 vmd*(保留 idle 或原动作)
🟡 P3broadcastMotionmodel-loader 加载是两条 inst.vmd* 写入路径,用户快速换/删模型时可能竞态引入 generation counter / 加载锁(参考 AGENTS「审核思维准则·并发与边界」):resolve 写入与加载写入互斥,过期广播(generation 不匹配)丢弃
🟡 P3场景级 store 为 singleton let + 函数,无 Observable;motionAssignment.status 变化时 UI 无法实时订阅刷新 incompatible 提示store 暴露轻量订阅(如 onMotionChange(cb) 薄 Observable / 事件);bind() 驱动的 UI 据此刷新 incompatible 状态
🟢 P4pinned?: SceneMotionIntent 与场景级 activeMotion 同型,未说明是快照还是共享引用注明 pinnedstructuredClone(activeMotion) 冻结,避免后续改 activeMotion 污染已 pin 实例
🟠 P2合奏/对舞场景被迫同舞pinned 覆盖层(必需项,非可选项);右键 pin 独立于全局
🟡 P3持久化引入场景级 activeMotion 字段仅扩展 scene-serialize.ts不入 EnvState,规避 Go struct 同步 + wails 绑定重生成本
🟢 P4scene-restore 各分支(normal/replace/scene-restore/prop)传参不一致导致回放错乱scene-restore 一并传入 motionAssignment,对齐 ADR-119 buildThumbnailKey 契约
🟢 P4resolve() 高频调用性能仅在 setActiveMotion / 模型加载 / pin/unpin 时解析一次,结果写入 inst.vmd* 缓存,每帧播放链路不变

不变的部分(P1 阶段)

模块不动原因
scene/motion/playback.ts / vmd-loader.ts / vmd-layers.ts 内部仍读 inst.vmdData/vmdName/vmdPath/vmdLayers,本 ADR 不改播放链路,只改写入方
ADR-116 动作覆盖模块 + motion-modules/registry.ts独立 per-model 层,全局意图只换基础 VMD 来源,覆盖模块照常叠加
scene/motion/animation-retargeter.ts可选重映射手段(外部动画源),非广播期兼容引擎;常规库 VMD 广播走 proc-motion-shared.matchBone
core/types.ts 现有 vmd* 字段作为已解析缓存保留,避免大范围重构

i18n 新增 key(5 语言:en / ja / ko / zh-CN / zh-TW,对应 core/i18n/locales/en.ts/ja.ts/ko.ts/zh-CN.ts/zh-TW.ts

Keyzh-CNjaenkozh-TW
motion.intent.title场上动作場上の動作Active Motion현재 동작場上動作
motion.intent.none静态(无动作)静止(動作なし)Static (no motion)정적 (동작 없음)靜態(無動作)
motion.intent.incompatible此角色不兼容当前动作このキャラは現在の動作非対応This model is incompatible with the active motion이 모델은 현재 동작과 호환되지 않음此角色不相容目前動作
motion.context.pinMotion固定此动作この動作を固定Pin this motion이 동작 고정固定此動作
motion.context.unpin跟随全局动作全体動作に追従Follow global motion전역 동작 따르기跟隨全域動作

后续迭代方向

  • 跨场景记住上次动作:若需「重开应用恢复上次动作」,可将 activeMotion 快照进 uiState(届时须同步 Go UIState struct + 重生成绑定,按工程铁律执行)——本 ADR P1/P2 不纳入,保持场景级范围。
  • 动作预设组:「演唱会包」一键设 activeMotion + 给特定角色 pin 独舞。
  • 批量 pin:多选模型统一指派动作。