Unity 角色动画模块的组合式重构
最近整理了一次角色动画模块。原先本地玩家、网络同步玩家和 AI 共用一个动画组件,随着手持物、送礼等行为接入,业务层开始出现越来越多“如果是其他玩家,就额外处理一下”的代码。
这次调整主要做了两件事:根据 Animator 的实际驱动方式拆分实现;让角色实体以普通 C# 对象的方式持有动画驱动器,负责它的创建与释放。下面保留这次重构时的思路,说明原先哪里不方便,以及新结构怎样消除这些特殊处理。
旧版的问题
共用一个类,却使用两套控制方式
旧版的 ModelPlayerAnim 挂在角色模型上,里面同时包含参数更新、状态切换、网络广播处理和手持动作加载等接口。
这些方法表面上都在操作 Animator,实际却依赖不同的上下文:
| 角色 | 输入来源 | 驱动动画的方式 |
|---|---|---|
| 本地玩家 | 本地输入与角色状态 | 设置参数,由 Animator 的过渡条件决定状态 |
| 远端玩家 | 网络收到的动画状态 | 根据状态信息直接播放或 CrossFade |
| AI 角色 | AI 决策 | 在当时的实现中,与本地玩家一样通过参数驱动 |
本地玩家需要 SetFloat、SetBool 等接口;远端玩家需要直接切换状态,却不应该重新执行一遍本地输入决策。把两条路线放进同一个类之后,每种角色都会看到一些自己根本不需要的方法。
参数驱动的控制器保留过渡条件,状态切换由 Animator 完成。
状态驱动的控制器主要接收明确的播放目标,两者需要的控制接口不同。
通用行为需要在外部补特例
手持物更新是一个很直观的例子。业务层只是想表达“现在进入手持状态”,但原来的接口主要按本地玩家设计,内部设置了 Animator 参数。
远端玩家的控制器不依赖这些参数过渡,于是调用者还得知道它的状态名、动画层和融合时间,再补一次 CrossFade。结果是动画知识泄漏到了物品、角色同步等模块里,同一个手持行为也有了两处维护入口。
下面是经过简化的旧调用方式:
1 | player.Animation.SetHolding(true); |
本地与远端的手持更新流程不同,已经引发过动作不一致的问题。当时通过补特殊处理解决了表现,但如果继续沿这个方向扩展,后面的每个新行为都有可能再复制一遍分支。
组件存在,不代表组件已经可用
旧动画组件会随角色一起实例化,但它依赖的模型、Animator 和骨架要等后续加载完成才真正有效。
旧结构把动画业务组件直接挂在模型上,组件的存在时间早于业务就绪时间。
为了避免空引用,方法内部经常写成“Animator 还没有就返回”。这种做法不会立即崩溃,却可能悄悄丢掉一次必要的状态更新。调用成功返回,并不能说明动作真的已经应用。
按驱动方式拆分实现
Controller 与 AnimDriver 分别组合
角色实体现在分别持有移动控制器和动画驱动器,两者不再互相决定对方的类型。可以把当时的组合关系写成下面的表:
| 动画驱动器 | 本地控制器 | 远端控制器 | AI 控制器 |
|---|---|---|---|
| 参数驱动 | 本地玩家 | — | AI 角色 |
| 状态驱动 | — | 远端玩家 | — |
这里按驱动方式区分动画实现,而不是为每一种玩家类型复制完整的动画模块。AI 与本地玩家的决策来源不同,但如果都使用同一套参数状态机,就可以复用参数驱动这一层。
实体负责把合适的对象组装起来;业务层面向行为接口调用,不再逐个判断玩家类型。
同一个业务接口采用不同实现
以手持动作为例,对外保留一个“设置手持状态”的接口。参数驱动器设置布尔参数,状态驱动器直接切换对应动画层的状态。
下面只展示这个差异,示例名称与具体业务配置无关:
1 | public abstract class AnimationDriver |
状态驱动器则把动画层、目标状态和过渡时间留在自己内部:
1 | public override void SetHolding(bool holding) |
这并不要求两种驱动器使用相同的底层操作。需要一致的是行为含义:调用 SetHolding(true) 后,该角色应当进入对应的手持表现。怎样让 Animator 得到这个结果,由各自的实现负责。
这样,手持物模块只需要更新资源并调用动画接口,不再维护远端玩家的状态名和过渡细节。
让实体管理生命周期
动画驱动器改为普通 C# 对象,由实体在依赖准备好之后创建,并在实体退出时释放。它不再依赖一个提前挂好的 MonoBehaviour 来表示“动画模块已经存在”。
1 | // 生命周期示意,模型加载与资源释放由所在项目的实体框架负责。 |
模型未就绪时应当怎样处理,要由调用方明确决定。必要的业务状态可以先保留,等模型就绪后重新应用;一个允许丢弃的瞬时表现则可以直接跳过。驱动器内部不再用同一个空判断悄悄处理所有情况。
例如远端角色还未完成生成时,调用点可以显式忽略一条过渡中的动画消息;但“当前正在手持什么”是持续状态,不能仅因为加载期间没有驱动器就永久丢失。
这次调整并没有让异步加载的问题消失,而是把“尚未就绪”和“操作已经执行”分开表达,排查时能够知道一次操作究竟在哪里被忽略。
这次重构解决的范围
重构后,参数驱动与状态驱动分别维护自己的过渡逻辑,公共行为通过一致的接口暴露,Controller 与 AnimDriver 可以按角色需要组合。后续接入动态手持动作时,就有了明确的实现位置。
这套拆分也有一个前提:项目确实存在两种不同的动画控制方式。如果所有角色都采用相同的驱动流程,没有必要仅为了增加层次而提前拆出多套类。
对这次问题来说,值得保留的判断依据是:当调用者必须反复了解某个角色的内部动画机制,才能正确调用一个看起来通用的接口时,应当回头检查这个接口的职责。