Unity 角色动画模块的组合式重构

最近整理了一次角色动画模块。原先本地玩家、网络同步玩家和 AI 共用一个动画组件,随着手持物、送礼等行为接入,业务层开始出现越来越多“如果是其他玩家,就额外处理一下”的代码。

这次调整主要做了两件事:根据 Animator 的实际驱动方式拆分实现;让角色实体以普通 C# 对象的方式持有动画驱动器,负责它的创建与释放。下面保留这次重构时的思路,说明原先哪里不方便,以及新结构怎样消除这些特殊处理。

旧版的问题

共用一个类,却使用两套控制方式

旧版的 ModelPlayerAnim 挂在角色模型上,里面同时包含参数更新、状态切换、网络广播处理和手持动作加载等接口。

这些方法表面上都在操作 Animator,实际却依赖不同的上下文:

角色 输入来源 驱动动画的方式
本地玩家 本地输入与角色状态 设置参数,由 Animator 的过渡条件决定状态
远端玩家 网络收到的动画状态 根据状态信息直接播放或 CrossFade
AI 角色 AI 决策 在当时的实现中,与本地玩家一样通过参数驱动

本地玩家需要 SetFloat、SetBool 等接口;远端玩家需要直接切换状态,却不应该重新执行一遍本地输入决策。把两条路线放进同一个类之后,每种角色都会看到一些自己根本不需要的方法。

通过过渡条件驱动的 Animator 状态机

参数驱动的控制器保留过渡条件,状态切换由 Animator 完成。

用于状态直驱的 Animator 状态布局

状态驱动的控制器主要接收明确的播放目标,两者需要的控制接口不同。

通用行为需要在外部补特例

手持物更新是一个很直观的例子。业务层只是想表达“现在进入手持状态”,但原来的接口主要按本地玩家设计,内部设置了 Animator 参数。

远端玩家的控制器不依赖这些参数过渡,于是调用者还得知道它的状态名、动画层和融合时间,再补一次 CrossFade。结果是动画知识泄漏到了物品、角色同步等模块里,同一个手持行为也有了两处维护入口。

下面是经过简化的旧调用方式:

1
2
3
4
5
6
7
player.Animation.SetHolding(true);

if (player.IsRemote)
{
// 调用者还要了解远端动画的实现细节。
player.Animator.CrossFade(holdState, blendTime, holdLayer);
}

本地与远端的手持更新流程不同,已经引发过动作不一致的问题。当时通过补特殊处理解决了表现,但如果继续沿这个方向扩展,后面的每个新行为都有可能再复制一遍分支。

组件存在,不代表组件已经可用

旧动画组件会随角色一起实例化,但它依赖的模型、Animator 和骨架要等后续加载完成才真正有效。

旧版动画业务组件挂在角色模型上

旧结构把动画业务组件直接挂在模型上,组件的存在时间早于业务就绪时间。

为了避免空引用,方法内部经常写成“Animator 还没有就返回”。这种做法不会立即崩溃,却可能悄悄丢掉一次必要的状态更新。调用成功返回,并不能说明动作真的已经应用。

按驱动方式拆分实现

Controller 与 AnimDriver 分别组合

角色实体现在分别持有移动控制器和动画驱动器,两者不再互相决定对方的类型。可以把当时的组合关系写成下面的表:

动画驱动器 本地控制器 远端控制器 AI 控制器
参数驱动 本地玩家 — AI 角色
状态驱动 — 远端玩家 —

这里按驱动方式区分动画实现,而不是为每一种玩家类型复制完整的动画模块。AI 与本地玩家的决策来源不同,但如果都使用同一套参数状态机,就可以复用参数驱动这一层。

角色实体分别持有移动控制器和动画驱动器的组合关系

实体负责把合适的对象组装起来;业务层面向行为接口调用,不再逐个判断玩家类型。

同一个业务接口采用不同实现

以手持动作为例,对外保留一个“设置手持状态”的接口。参数驱动器设置布尔参数,状态驱动器直接切换对应动画层的状态。

下面只展示这个差异,示例名称与具体业务配置无关:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class AnimationDriver
{
public abstract void SetHolding(bool holding);
public abstract void Dispose();
}

public sealed class ParameterAnimationDriver : AnimationDriver
{
private readonly Animator animator;
private static readonly int Holding = Animator.StringToHash("Holding");

public ParameterAnimationDriver(Animator animator)
=> this.animator = animator;

public override void SetHolding(bool holding)
=> animator.SetBool(Holding, holding);

public override void Dispose() { }
}

状态驱动器则把动画层、目标状态和过渡时间留在自己内部:

1
2
3
4
5
public override void SetHolding(bool holding)
{
int targetState = holding ? holdStateHash : emptyStateHash;
animator.CrossFade(targetState, transitionDuration, upperBodyLayer);
}

这并不要求两种驱动器使用相同的底层操作。需要一致的是行为含义:调用 SetHolding(true) 后,该角色应当进入对应的手持表现。怎样让 Animator 得到这个结果,由各自的实现负责。

这样,手持物模块只需要更新资源并调用动画接口,不再维护远端玩家的状态名和过渡细节。

让实体管理生命周期

动画驱动器改为普通 C# 对象,由实体在依赖准备好之后创建,并在实体退出时释放。它不再依赖一个提前挂好的 MonoBehaviour 来表示“动画模块已经存在”。

1
2
3
4
5
6
7
8
9
10
11
12
// 生命周期示意,模型加载与资源释放由所在项目的实体框架负责。
void OnModelReady(Animator animator)
{
animationDriver = CreateAnimationDriver(animator);
ApplyCurrentPresentation();
}

void OnEntityHide()
{
animationDriver?.Dispose();
animationDriver = null;
}

模型未就绪时应当怎样处理,要由调用方明确决定。必要的业务状态可以先保留,等模型就绪后重新应用;一个允许丢弃的瞬时表现则可以直接跳过。驱动器内部不再用同一个空判断悄悄处理所有情况。

例如远端角色还未完成生成时,调用点可以显式忽略一条过渡中的动画消息;但“当前正在手持什么”是持续状态,不能仅因为加载期间没有驱动器就永久丢失。

这次调整并没有让异步加载的问题消失,而是把“尚未就绪”和“操作已经执行”分开表达,排查时能够知道一次操作究竟在哪里被忽略。

这次重构解决的范围

重构后,参数驱动与状态驱动分别维护自己的过渡逻辑,公共行为通过一致的接口暴露,Controller 与 AnimDriver 可以按角色需要组合。后续接入动态手持动作时,就有了明确的实现位置。

这套拆分也有一个前提:项目确实存在两种不同的动画控制方式。如果所有角色都采用相同的驱动流程,没有必要仅为了增加层次而提前拆出多套类。

对这次问题来说,值得保留的判断依据是:当调用者必须反复了解某个角色的内部动画机制,才能正确调用一个看起来通用的接口时,应当回头检查这个接口的职责。