8.1 帧动画与精灵表播放
第 2 章我们把图集切成了一个个 AtlasFrame,第 7 章主角已经能撞墙、能落地。但它仍然是一张静止的图——跑步时脚不动,跳跃时姿势不变,看起来像一块被推着走的贴纸。这一节解决「动」的最原始形态:帧动画(frame animation),也就是把若干张连续姿态的图按顺序快速播放,利用视觉暂留骗出运动感。
本节把 mini2d 推进到「会动」:设计一个由时间轴驱动的
FramePlayer,支持单次、循环、乒乓三种播放模式,把帧率与播放速度解耦,并加入事件帧回调。最后对照 Cocos Creator 3.8 的Animation/AnimationClip/AnimationState,看这套手写逻辑在编辑器里由哪些资源与组件承载。
8.1.1 帧动画的本质:一条时间轴加一个索引
最朴素的帧动画是「每过一个固定时间就换下一张图」。把它形式化成两个量:
- 时间轴
t:从播放开始累计的秒数,每帧加上deltaTime。 - 帧索引
i:当前该显示第几张图,由t换算而来。
换算公式只有一个:
// 每帧时长 frameDuration = 1 / fps
const frameIndex = Math.floor(t / frameDuration);
只要 t 单调递增,frameIndex 就按序推进。这里 fps 是这套动画自身的帧率,与游戏渲染帧率完全无关——游戏可能跑 60 FPS,而这段动画的 fps 是 12,意味着一秒换 12 张图。把这两个概念分开,是帧动画设计的第一个关键决定。
先定义一份最简的动画描述与类型:
// src/anim/frame-clip.ts
export type WrapMode = 'once' | 'loop' | 'pingpong';
export interface FrameClip {
/** 依次引用的帧名,对应图集里的 frame.name */
frames: string[];
/** 播放帧率:每秒切换多少张图 */
fps: number;
/** 播放模式 */
wrapMode: WrapMode;
/** 事件帧:在第几帧触发,触发什么 */
events?: FrameEvent[];
}
export interface FrameEvent {
/** 帧下标(0 基) */
frame: number;
/** 事件名,交给上层处理 */
name: string;
}
注意这里没有任何 cc 依赖,也没有 enum——WrapMode 用字符串联合类型表达,既能被 tsc 检查,又能在 JSON 里直接书写。
8.1.2 播放模式:单次、循环、乒乓
三种模式的区别只体现在「frameIndex 越界后怎么办」:
| 模式 | 越界行为 | 典型用途 |
|---|---|---|
once | 停在最后一帧,标记播放结束 | 跳跃、受击、死亡 |
loop | 回绕到第 0 帧继续 | 跑步、待机呼吸 |
pingpong | 反向播放,到头部再折返 | 巡逻、警示灯闪烁 |
loop 的实现是取模,once 是钳制,pingpong 需要把「去程 + 回程」拼成一条 2N 的虚拟时间轴再取模。三种模式用同一个函数统一处理:
// src/anim/wrap.ts
import { WrapMode } from './frame-clip';
export interface WrappedFrame {
index: number;
/** 是否已到达终点(仅 once 有意义) */
finished: boolean;
}
export function wrapFrameIndex(
rawIndex: number,
frameCount: number,
mode: WrapMode,
): WrappedFrame {
if (frameCount <= 1) {
return { index: 0, finished: true };
}
if (mode === 'once') {
if (rawIndex >= frameCount) {
return { index: frameCount - 1, finished: true };
}
return { index: Math.max(0, rawIndex), finished: false };
}
if (mode === 'loop') {
// 取模保证非负
return { index: ((rawIndex % frameCount) + frameCount) % frameCount, finished: false };
}
// pingpong:2N-2 的周期,回程不含两个端点
const period = frameCount * 2 - 2;
const phase = ((rawIndex % period) + period) % period;
const index = phase < frameCount ? phase : period - phase;
return { index, finished: false };
}
pingpong 的周期是 2N - 2 而不是 2N,因为首尾两帧在折返点不该被播放两次,否则会看到「卡一下」。这是手写帧动画最容易错的一处细节。
8.1.3 帧率与播放速度解耦
如果只想让动画播得快一点,最直觉的做法是改 fps。但 fps 描述的是「这段动画的采样密度」,改它会改变动画的语义。真正该改的是一个独立的播放速度倍率 speed:
// 每帧推进的时间不是 deltaTime,而是 deltaTime * speed
this.elapsed += deltaTime * this.speed;
这样同一条 FrameClip 可以复用:受击动画正常速度播、被减速道具命中时 speed = 0.5 慢放、冲刺时 speed = 2 加速。换算帧索引时用「累计时间 × fps」:
const rawIndex = Math.floor(this.elapsed * clip.fps);
把 speed 与 fps 分开还有一个好处:动画时长可预测——一段 N 帧、fps 为 f 的动画,正常速度下时长是 (N - 1) / f 秒,与渲染帧率无关。
8.1.4 事件帧:在指定帧回调
帧动画常要在某一帧触发副作用:跑步动画在第 3 帧落脚印、攻击动画在第 2 帧判定伤害、跳跃动画在起跳帧播放音效。这类「第几帧做什么」就是事件帧(event frame)。
事件帧的核心是「只在跨过该帧的那一帧触发一次」。不能简单地判断 index === event.frame,因为若某帧 deltaTime 较大、索引从 2 直接跳到 4,第 3 帧的事件就被漏掉了。正确做法是记录上一帧索引,检测区间 (prev, cur] 是否覆盖了事件帧——用「左开右闭」而非闭区间,能保证同一个事件在连续两帧之间只触发一次,也不会在起始帧误触发。8.1.5 的 FramePlayer 里有一个 crossed 私有方法做的正是这件事,并且兼顾了 pingpong 回程时索引递减的情况。
用「左开右闭」区间 (prev, cur] 而不是 [prev, cur],能保证连续两帧之间同一个事件只触发一次,也不会在起始帧误触发。
8.1.5 FramePlayer:把时间轴、模式与事件接起来
现在把前面的零件组装成一个 FramePlayer。它持有 FrameClip 的运行时状态,每帧 update 一次,对外暴露当前帧名与完成状态:
// src/anim/frame-player.ts
import { FrameClip, FrameEvent } from './frame-clip';
import { wrapFrameIndex } from './wrap';
export type FrameEventListener = (event: FrameEvent) => void;
export class FramePlayer {
clip: FrameClip;
speed = 1;
/** 已累计的播放时间(秒) */
private elapsed = 0;
private prevIndex = 0;
private listeners: FrameEventListener[] = [];
constructor(clip: FrameClip) {
this.clip = clip;
}
onEvent(fn: FrameEventListener): void {
this.listeners.push(fn);
}
get finished(): boolean {
const { finished } = wrapFrameIndex(
Math.floor(this.elapsed * this.clip.fps),
this.clip.frames.length,
this.clip.wrapMode,
);
return finished;
}
/** 当前应显示的帧名 */
get frameName(): string {
const raw = Math.floor(this.elapsed * this.clip.fps);
const { index } = wrapFrameIndex(raw, this.clip.frames.length, this.clip.wrapMode);
return this.clip.frames[index];
}
reset(): void {
this.elapsed = 0;
this.prevIndex = 0;
}
update(deltaTime: number): void {
if (this.finished) return;
this.elapsed += deltaTime * this.speed;
const raw = Math.floor(this.elapsed * this.clip.fps);
const { index } = wrapFrameIndex(raw, this.clip.frames.length, this.clip.wrapMode);
this.fireEvents(this.prevIndex, index);
this.prevIndex = index;
}
private fireEvents(prev: number, cur: number): void {
const events = this.clip.events;
if (!events) return;
for (const ev of events) {
if (this.crossed(prev, cur, ev.frame)) {
for (const fn of this.listeners) fn(ev);
}
}
}
private crossed(prev: number, cur: number, target: number): boolean {
if (cur >= prev) return target > prev && target <= cur;
return target < prev && target >= cur;
}
}
get finished 与 get frameName 每次都重算索引,好处是无状态、不会读到脏值;代价只是一次整数除法,在 60 FPS 下可忽略。
8.1.6 接进渲染:让 SpriteNode 显示当前帧
FramePlayer 只算索引,真正画图的是 2.2 写的 SpriteNode。给节点加一个「按帧名取 AtlasFrame 并更新源矩形」的小方法,就完成了接线:
// src/scene/animated-sprite-node.ts
import { SpriteNode } from './sprite-node';
import { AtlasData, AtlasFrame } from '../render/atlas';
import { FramePlayer } from '../anim/frame-player';
export class AnimatedSpriteNode extends SpriteNode {
private frameMap: Map<string, AtlasFrame>;
private player: FramePlayer;
constructor(frameMap: Map<string, AtlasFrame>, player: FramePlayer) {
const first = frameMap.get(player.frameName);
if (!first) throw new Error('首帧缺失');
super({} as never, first.x, first.y, first.w, first.h);
this.frameMap = frameMap;
this.player = player;
}
update(deltaTime: number): void {
this.player.update(deltaTime);
const frame = this.frameMap.get(this.player.frameName);
if (frame) {
this.sprite.sx = frame.x;
this.sprite.sy = frame.y;
this.sprite.sw = frame.w;
this.sprite.sh = frame.h;
}
}
}
这里直接改 sprite 的源矩形字段即可——2.2 的 Sprite.draw 每次都用当前值调 drawImage,所以切换帧是零额外成本的。一张图集纹理 + 不断变化的源矩形,就是帧动画在渲染层的全部真相。
8.1.7 Cocos 对照:Animation、AnimationClip 与 AnimationState
Cocos Creator 3.8 里,帧动画由资源和组件两层构成。AnimationClip 是资源,描述「哪条属性曲线在什么时间取什么值」;Animation 是挂在节点上的组件,负责播放。对照 mini2d 的 FrameClip + FramePlayer,映射关系是:
| mini2d | Cocos Creator 3.8 | 说明 |
|---|---|---|
FrameClip.frames | AnimationClip 里的 spriteFrame 轨道 | 逐帧引用 SpriteFrame |
FrameClip.fps | AnimationClip.sample | 采样率,默认 60 |
FrameClip.wrapMode | AnimationClip.wrapMode | WrapMode 类型 |
FramePlayer.speed | AnimationState.speed | 播放速度倍率 |
FramePlayer.onEvent | AnimationClip.events | 事件帧,见下 |
FramePlayer.finished | AnimationState 的 'finished' 事件 | 播放完成回调 |
在编辑器里做一段逐帧动画,是把多个 SpriteFrame 拖进 Animation 面板的时间轴、每个关键帧放一帧图,然后设 sample 与 wrapMode。脚本侧只需要拿到组件并播放:
// assets/scripts/HeroAnim.ts
import { _decorator, Component, Animation, AnimationState, AnimationClip } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('HeroAnim')
export class HeroAnim extends Component {
@property(AnimationClip)
runClip: AnimationClip | null = null;
private anim: Animation | null = null;
onLoad() {
this.anim = this.getComponent(Animation);
if (this.anim && this.runClip) {
const state: AnimationState = this.anim.createState(this.runClip, 'run');
state.speed = 1.5;
state.wrapMode = AnimationClip.WrapMode.Loop;
}
}
playRun() {
this.anim?.play('run');
}
}
几点需要点明:
Animation组件本身就是「播放器」,play(name)/stop()/pause()/resume()/crossFade(name, duration)是它的主要方法;crossFade就是 8.3 要讲的过渡混合,引擎内建。getState(name)返回AnimationState,它是某个 clip 在该组件上的运行时实例;speed、wrapMode、repeatCount、ratio、current都在它上面。AnimationState还有setTime(t)与sample()可直接定位到某一时刻。- 事件帧在 Cocos 里是
AnimationClip.events(AnimationClip.IEvent[]),每个事件带frame、func、params,到帧自动调用,对应我们手写的FrameEvent+ 监听器。 - 播放完成监听
AnimationState的'finished'事件,等价于FramePlayer.finished。 - 若要播放的是骨骼动画而非逐帧图,组件换成
SkeletalAnimation(它继承自Animation),API 一致,只是数据来自骨骼曲线。2D 项目用不到,但概念上同源。
一句话总结:mini2d 把「帧序列 + 时间轴 + 播放模式 + 事件」压进一个 FramePlayer 类;Cocos 把它们拆成 AnimationClip(资源)+ Animation(组件)+ AnimationState(运行时实例)三层。拆分的收益是资源可跨节点复用、播放状态可独立控制,代价是新手容易搞不清「我改的到底是资源还是实例」。
下一节我们离开逐帧图,进入连续量动画的世界——补间(tween)与缓动曲线,看看如何用一条数学函数把位置、缩放、透明度在时间上平滑地插值出来。
阅读导航:上一节:目录 · 下一节:8.2 补间与缓动曲线的数学 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。