《Cocos 游戏开发实战》9.1 Animation 编辑器与动画剪辑

跑到第 9 章,《方块冲刺》的角色还在用代码改 scale 假装在动。本节把动画交还给编辑器:用 Animation 组件与 AnimationClip 资源做一条 run 剪辑,讲清关键帧、采样率与 wrapMode 的取舍,给出 .anim 文件在编辑器里打开的样子,并写一个可编译的 crossFade 播放组件。

9.1 Animation 编辑器与动画剪辑

第 7、8 章我们把《方块冲刺》的玩法跑通了:角色会自动向前,点一下跳一下,碰到障碍就掉血。但角色从头到尾是一个不动的方块,跳跃靠代码硬改 scale 假装「挤一下」,金币是一张静止的图。这些都能骗过截图,骗不过玩家——没有动画的跑酷,手感是死的。

这一章专门解决「动起来」这件事。站内 game/client/ 那批工程文讲的是 Godot、Phaser 怎么挂动画状态机,本卷不重复;我们只讲 Cocos Creator 3.8 自己那套:Animation 组件、AnimationClip 资源、以及它们在编辑器里的操作路径。

9.1.1 先想清楚:哪些东西该交给编辑器

卷一里我们用纯代码做过动画——每帧给 x 加一个速度,或者用 lerp 在两个值之间插。这在「一个数在变」的时候很好使,但一旦要动的是一整个姿态(角色的手脚、受击时整块方块的形变、金币的旋转加缩放),手写插值就崩了:你要维护十几个属性、每条各自一条曲线、还要处理循环和过渡。

Cocos 把这类需求抽象成两样东西:

概念是什么在《方块冲刺》里的例子
AnimationClip一份资源,存关键帧曲线run.anim、coin-spin.anim
Animation一个组件,挂在节点上,负责播放 clip挂在 Player 节点上

关键帧曲线的意思是:你只给出几个关键时刻的属性值,中间由引擎按插值补出来。角色的「跑」不需要 24 张图,只要 3 个关键帧描述腿的摆动,引擎每帧采样就能画出连续动作。

到底什么时候该开编辑器、什么时候该写代码?把三种做法摆在一起比一比就清楚了:

做法适合改动成本《方块冲刺》里的用途
纯代码改属性单个值、逻辑驱动改代码相机跟随
tween(9.2 节)单个值、一次性动效改代码,最快金币旋转、弹窗弹入
AnimationClip多属性协同、需反复播放开编辑器,美术可改角色跑/跳/受击

分工原则:能用一个数描述的交给 Tween(9.2 节),需要多属性协同的交给 Animation 编辑器。金币旋转是单属性,其实 Tween 更省事;角色跑动是多属性,必须用 clip。这个判断标准贯穿整章。

9.1.2 编辑器里做一条 run 剪辑

下面这一步属于 L3——需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。你照着点,做完会得到 assets/animations/run.anim。

  1. 层级管理器里选中 Player 节点。
  2. 属性检查器底部点「添加组件」→ 搜 Animation → 添加。此时 Player 上多了一个 Animation 组件,Clips 数组是空的。
  3. 点该组件右上角的「进入动画编辑模式」(或菜单 Animation → Animation Editor)。
  4. 在动画编辑器里点「新建剪辑」,命名 run,保存路径选 assets/animations/。编辑器会自动生成 run.anim,并把它挂到 Animation 组件的 Clips 数组第 0 项。
  5. 在轨道区点「添加属性」→ 选 position、scale,各加一条轨道。
  6. 时间轴拖到 0 秒,给两条轨道打关键帧;拖到 0.2 秒,再打一组;拖到 0.4 秒,回到和第 0 秒相同的值。

操作完成后,属性面板上应显示:

  • Animation 组件的 Clips 长度为 1,元素是 run;
  • Default Clip 自动被设为 run;
  • Play On Awake 默认勾选(角色一进场就自己跑);
  • run.anim 的 Duration 显示 0.40,Sample 显示 60。

Duration 是这条剪辑的时长,Sample 是采样率(每秒采多少帧)。0.4 秒配 60 采样,意味着引擎在 0.4 秒内会取 24 个中间点来插值。采样率不是越高越好——它只影响曲线精度,不影响文件里存的关键帧数量(关键帧还是 3 个)。

9.1.3 .anim 文件在编辑器里打开会看到什么

run.anim 是一份 JSON。需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现,但读一眼能理解「关键帧曲线」的磁盘形态:

{
  "__type__": "cc.AnimationClip",
  "_name": "run",
  "_duration": 0.4,
  "_sample": 60,
  "_wrapMode": 2,
  "_speed": 1,
  "_tracks": [
    {
      "__type__": "cc.animation.VectorTrack",
      "_path": { "__id__": 1 },
      "_channel": { "__id__": 2 }
    }
  ]
}

几处要点:

  • __type__ 是 cc.AnimationClip,说明这是一份剪辑资源,不是节点;
  • _duration: 0.4 与编辑器里显示的 0.40 对应;
  • _wrapMode: 2 就是 WrapMode.Loop(枚举值 2),所以这条跑动会一直循环;
  • _tracks 里每条轨道通过 __id__ 指向文件后面定义的对象,和 .scene 一样是一张用 __id__ 串起来的对象图。

打开编辑器后你会看到:资源管理器里 assets/animations/ 下多了 run.anim 和一个同名的 run.anim.meta;双击 run.anim 会打开动画编辑器,里面是那 3 个关键帧的曲线视图,横轴是时间、纵轴是属性值。

.anim.meta 里有一个 uuid 字段。不要手动改它——代码里 crossFade('run') 用的是剪辑名,但引擎内部按 uuid 找资源;uuid 一旦和资源对不上,编辑器重启后会重新生成一份新的 .anim,旧的引用全部失效。这条坑在 18.2 的复盘清单里还会出现。

9.1.4 用代码驱动动画:一个可编译的播放组件

Play On Awake 只能应付「一进场就循环」的场景。跑酷要的是状态切换:待机切跑动、跳跃切空中、受击切闪烁。这需要代码介入。下面这个组件可以过 tsc --noEmit,符号都来自 cc.d.ts:

import { _decorator, Animation, Component } from 'cc';

const { ccclass, property } = _decorator;

@ccclass('PlayerAnimator')
export class PlayerAnimator extends Component {
  @property(Animation)
  private anim: Animation | null = null;

  private current = '';

  protected onLoad(): void {
    if (!this.anim) {
      this.anim = this.getComponent(Animation);
    }
    if (!this.anim) {
      return;
    }
    // 剪辑播完(wrapMode 非 Loop 时)触发一次
    this.anim.on(Animation.EventType.FINISHED, this.onClipFinished, this);
  }

  /** 切到某个剪辑;同名则不重复触发,避免每帧打断过渡 */
  crossTo(name: string, fade = 0.1): void {
    if (!this.anim || this.current === name) {
      return;
    }
    this.current = name;
    this.anim.crossFade(name, fade);
  }

  /** 立刻重播当前剪辑,用于受击这种「打断重来」 */
  restart(name: string): void {
    if (!this.anim) {
      return;
    }
    this.current = name;
    this.anim.play(name);
  }

  private onClipFinished(): void {
    this.current = '';
  }
}

三个 API 是这段代码的骨架:

  • Animation.crossFade(name, duration):在 duration 秒内从当前剪辑淡入到目标剪辑。它是跑酷里最该用的方法——待机切跑动、跑动切跳跃,都不会「啪」地硬切。
  • Animation.play(name):直接播放,不做过场。受击这种要求立刻响应的场合才用。
  • Animation.EventType.FINISHED:事件常量,挂在 Animation.on 上监听某个剪辑播完。这个 EventType 是 Animation 类上的静态成员(类型为枚举 AnimationStateEventType,FINISHED 的值是字符串 "finished"),所以能当值用;别写成 AnimationState.EventType——AnimationState 上并没有这个静态成员,tsc 会直接报 TS2339。

玩法代码那边只跟 PlayerAnimator 打交道:

// 在 PlayerController 里
onJump(): void {
  this.animator.crossTo('jump', 0.05);
}

onHurt(): void {
  this.animator.restart('hurt');
}

9.1.5 状态机为什么要自己维护

上面代码里的 this.current 看起来多余,其实是必要的。crossFade 每次调用都会新建一个淡入过程;如果跳跃逻辑在每帧的 update 里都调一次 crossTo('jump'),就会每帧打断上一条过渡,动画永远停在起点——这是新手最常见的一个 bug。

this.current 挡住了重复调用。真正的状态机(idle → run → jump → land)会复杂得多,但那属于卷三「引擎内部机制」的范畴。卷二先记住一条:切换动画前先比较当前状态。

AnimationState 上还有两个调参入口,值得知道:

属性作用跑酷里的用法
speed播放倍率角色加速时把跑动剪辑的 speed 提到 1.3
repeatCount循环次数受击闪烁播 3 次就停,设成 3

wrapMode 决定播完的行为,取值是 WrapMode.Loop(2,循环)、WrapMode.Normal(1,播一遍停住)。跑动要 Loop,跳跃和受击要 Normal。这三个属性都能在编辑器里设默认值,也能在代码里通过 Animation.getState(name) 拿到 AnimationState 后实时改。

9.1.6 在剪辑里埋事件:给第 10 章的音效留接口

动画和音频天生要配合:角色跑到某一帧脚落地,就该响一声脚步;跳跃到最高点,可以触发一次粒子。Cocos 允许在剪辑里埋动画事件——每个事件就是一个 frame 加一个 func 名(还有可选的 params),播到那一帧时引擎回调。

在动画编辑器里:把时间轴拖到目标帧 → 点轨道上方的「添加事件」按钮 → 在弹出框里填 func 为 onFootStep。存盘后 run.anim 里会多出一个 _events 数组(需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现),形如:

"_events": [
  { "frame": 12, "func": "onFootStep", "params": [] }
]

代码侧不用注册监听,只要在同一个节点的某个组件上实现一个同名方法即可(第 10 章会把这个回调接到音效池上):

// 方法名必须与剪辑事件里的 func 一致,签名接收一个可选的帧参数
onFootStep(frame: number): void {
  // 第 10 章在这里调 AudioManager.playOneShot('footstep')
  void frame;
}

事件和 9.1.4 里那个 FINISHED 是两套机制:FINISHED 通过 Animation.on 注册、表示「整条播完」;自定义事件靠 func 名字在节点上找同名方法、表示「播到第 N 帧」。前者是事件监听,后者是方法回调,别混。

9.1.7 用纯逻辑验证「关键帧采样」

引擎的曲线采样是黑盒,但它的数学很简单,值得自己写一遍。下面这段与引擎无关,可直接单测:

export interface Key {
  time: number;
  value: number;
}

/** 在关键帧之间做线性采样,t 超出范围时钳到首尾 */
export function sampleLinear(keys: Key[], t: number): number {
  const first = keys[0];
  const last = keys[keys.length - 1];
  if (t <= first.time) {
    return first.value;
  }
  if (t >= last.time) {
    return last.value;
  }
  for (let i = 1; i < keys.length; i += 1) {
    const b = keys[i];
    if (t <= b.time) {
      const a = keys[i - 1];
      const k = (t - a.time) / (b.time - a.time);
      return a.value + (b.value - a.value) * k;
    }
  }
  return last.value;
}

用它对照 run.anim:给 scale.y 一组 [{0, 1}, {0.2, 0.85}, {0.4, 1}],在 t = 0.1 采样应得 0.925。这个数和编辑器曲线视图在 0.1 秒处的读数一致——你可以用它验证「我理解的关键帧插值」和引擎实际做的是不是一回事。

9.1.8 采样率与包体的取舍

Sample 设 60 还是 30?很多教程会说「越高越平滑」,但对《方块冲刺》这种 2D 小游戏,30 完全够用,60 只会让 .anim 文件更大、编辑器预览更吃 CPU。真正影响观感的是关键帧的位置,不是采样率。

判断方法:在编辑器曲线视图里把采样率从 60 调到 30,如果曲线形状看不出变化,就说明 30 够用。跑酷的跑动剪辑几乎都是「几个点之间线性插」,30 采样完全无损。

9.1.9 工程视角的收口

这一节做完,工程里多了这些:

  • assets/animations/ 目录(新建),内含 run.anim、coin-spin.anim 及各自的 .meta;
  • Player 节点上多了一个 Animation 组件,Clips 指向 run;
  • assets/scripts/game/player-animator.ts,对外只暴露 crossTo / restart 两个方法。

下一节接手的入口很清楚:PlayerAnimator.crossTo 就是动画层的唯一开关,玩法代码只调它,不直接碰 Animation 组件。这样 9.2 用 Tween 做金币、9.3 接粒子和骨骼时,三套表现系统各管一摊,不会互相踩。

接下来 9.2 会讲另一条路:当动画只是「一个数在变」时,用 tween 比开编辑器快得多,还能在代码里随手调曲线。

阅读导航:上一节:目录 · 下一节:9.2 Tween 与程序动画 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画