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。
- 层级管理器里选中
Player节点。 - 属性检查器底部点「添加组件」→ 搜
Animation→ 添加。此时Player上多了一个Animation组件,Clips数组是空的。 - 点该组件右上角的「进入动画编辑模式」(或菜单
Animation → Animation Editor)。 - 在动画编辑器里点「新建剪辑」,命名
run,保存路径选assets/animations/。编辑器会自动生成run.anim,并把它挂到Animation组件的Clips数组第 0 项。 - 在轨道区点「添加属性」→ 选
position、scale,各加一条轨道。 - 时间轴拖到 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 与程序动画 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。