《Cocos 游戏开发入门》8.1 帧动画与精灵表播放

本节把图集里的一格格精灵接成会动的帧动画:用时间轴把已播放秒数换算成帧索引,实现单次、循环与乒乓三种播放模式,把帧率与播放速度解耦,并加入事件帧回调;再用纯 TypeScript 写出 FramePlayer,最后对照 Cocos Creator 3.8 的 Animation、AnimationClip 与 AnimationState。

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,映射关系是:

mini2dCocos Creator 3.8说明
FrameClip.framesAnimationClip 里的 spriteFrame 轨道逐帧引用 SpriteFrame
FrameClip.fpsAnimationClip.sample采样率,默认 60
FrameClip.wrapModeAnimationClip.wrapModeWrapMode 类型
FramePlayer.speedAnimationState.speed播放速度倍率
FramePlayer.onEventAnimationClip.events事件帧,见下
FramePlayer.finishedAnimationState 的 '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 补间与缓动曲线的数学 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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