《Cocos 游戏开发入门》8.3 属性动画与状态驱动表现

本节把帧动画与补间收束成由状态驱动的表现层:定义 idle、run、jump、hurt 四态与转移规则,用权重做过渡混合、优先级处理打断;再手写 HeroPresenter 接进 mini2d,最后对照 Cocos Creator 3.8 的 crossFade、AnimationState、SkeletalAnimation 与 ParticleSystem2D。

8.3 属性动画与状态驱动表现

8.1 教会了单个对象怎么播帧动画,8.2 教会了单个属性怎么插值。但真实的主角不是「一段动画」,而是一个会切换姿态的实体:站着时呼吸、跑动时循环跑帧、起跳时切跳跃帧、被撞时闪红。如果把这些逻辑全塞进 update 里的一串 if,很快就会变成一坨无法维护的条件判断。

这一节引入一个中间层——表现状态机(presentation state machine)。它不关心物理,只关心「当前处于什么姿态、这个姿态该播什么、切换时怎么过渡」。把表现从逻辑里拆出来,是让角色动画可维护的关键一步。

本节把 mini2d 推进到「有状态的表现层」:定义 idle / run / jump / hurt 四态与转移规则,用权重做过渡混合、用优先级处理打断,手写一个 HeroPresenter 把 8.1 的帧动画与 8.2 的补间接起来。最后对照 Cocos Creator 3.8 的 Animation.crossFade、AnimationState、SkeletalAnimation 与 ParticleSystem2D。

8.3.1 为什么需要状态驱动

先看反面例子——直接在主循环里堆判断:

// 反例:表现逻辑与游戏逻辑纠缠
if (hero.hurtTimer > 0) {
  setFrame('hurt');
} else if (!hero.onGround) {
  setFrame(hero.vy > 0 ? 'jump_up' : 'jump_down');
} else if (Math.abs(hero.vx) > 1) {
  setFrame('run');
} else {
  setFrame('idle');
}

它能跑,但问题在于:加一个「冲刺」状态要改这里,加一个「落地缓冲」要改这里,过渡混合、打断、事件帧也全没地方放。状态机的价值是把「什么条件切到什么状态」和「每个状态怎么表现」分开——前者是转移表,后者是状态定义。两者解耦后,加状态只需加一行配置。

8.3.2 表现状态机:状态与转移

先定义状态名与转移规则。用字符串联合而非 enum,与 8.1 保持一致:

// src/anim/hero-states.ts
export type HeroState = 'idle' | 'run' | 'jump' | 'hurt';

export interface TransitionRule {
  to: HeroState;
  /** 该转移的优先级:数值越大越能打断低优先级的当前状态 */
  priority: number;
  /** 进入该状态时的过渡时长(秒) */
  blend: number;
}

四态的典型语义:

状态帧动画循环优先级备注
idleidle 序列循环0呼吸、眨眼,最低优先级
runrun 序列循环1播放速度跟移动速度挂钩
jumpjump 序列单次2落地后回落
hurthurt 序列单次3受击最高优先级,能打断一切

优先级是打断规则的核心:hurt(3)可以打断 run(1),但 run 不能打断正在播的 hurt。没有优先级,一次受击就会被下一帧的移动逻辑冲掉,玩家根本看不到受击动画。

8.3.3 属性动画:把状态映射到属性

每个状态不只对应一段帧动画,还对应一组属性目标——位置偏移、缩放、颜色。比如受击状态同时做三件事:播 hurt 帧、横向抖动、整体闪白。这就是「属性动画」:把状态映射到一组可插值的属性上。

// src/anim/state-def.ts
import { HeroState } from './hero-states';

export interface StateDef {
  clip: string;
  loop: boolean;
  /** 进入该状态时的属性目标,由 8.2 的补间驱动 */
  props?: Record<string, number>;
}

export const HERO_STATES: Record<HeroState, StateDef> = {
  idle: { clip: 'idle', loop: true, props: { scale: 1 } },
  run: { clip: 'run', loop: true, props: { scale: 1 } },
  jump: { clip: 'jump', loop: false, props: { scale: 0.92 } },
  hurt: { clip: 'hurt', loop: false, props: { scale: 1.1 } },
};

props 是声明式的:进入 hurt 时,表现层自动对 scale 跑一段「放大到 1.1 再回 1」的补间,不需要在每个状态里手写动画代码。声明式的好处是新增状态只加配置,不动逻辑。

8.3.4 过渡混合:crossFade 与权重

状态切换时直接硬切会很生硬——跑步突然变跳跃,会「啪」地跳一下。**过渡混合(cross fade)**在 blend 秒内让旧状态淡出、新状态淡入:

// 权重从 0 线性升到 1,旧状态权重为 1 - 权重
const w = Math.min(blendElapsed / blendDuration, 1);
// 混合两个状态的属性值
const value = oldValue * (1 - w) + newValue * w;

对我们这套「帧动画 + 属性」的表现层,混合分两部分:

  • 帧动画无法真正混合两帧图(除非用透明度叠加),常见做法是「淡入淡出两套精灵」或直接硬切。mini2d 采用硬切 + 属性混合的折中:帧直接换,但 scale、opacity 这些数值属性做平滑过渡,视觉上已经足够顺滑。
  • 数值属性用 8.2 的 lerp 做混合,天然支持任意多条属性的插值。
// src/anim/blend.ts
export function blendValue(from: number, to: number, w: number): number {
  return from + (to - from) * w;
}

8.3.5 打断与优先级

打断(interruption)发生在「正在过渡时又来了新转移」。规则很简单:

  1. 新转移的 priority 大于当前状态优先级 → 允许打断,从当前混合进度重新开始一段新过渡。
  2. 否则忽略,等当前过渡结束。

有一处细节容易踩坑:打断时不能把 scale 拉回初始值再混合,否则会看到「抖一下」。正确做法是记录打断瞬间的当前实际值,把它当作新过渡的起点:

// 打断时,以当前渲染值为新起点
const interruptedFrom = this.currentScale;
this.blendFrom = interruptedFrom;
this.blendTo = targetScale;
this.blendElapsed = 0;

这样无论在哪一帧打断,数值都是连续的,不会跳变。这个「以当前值为起点」的思路和 8.2 链式补间里 to 读当前值是一回事。

8.3.6 HeroPresenter:把三层接起来

现在把状态机、帧动画、属性补间组装成 HeroPresenter:

// src/anim/hero-presenter.ts
import { FramePlayer } from './frame-player';
import { FrameClip } from './frame-clip';
import { HeroState } from './hero-states';
import { HERO_STATES } from './state-def';

export interface HeroClips {
  [clip: string]: FrameClip;
}

export class HeroPresenter {
  private clips: HeroClips;
  private player: FramePlayer;
  private state: HeroState = 'idle';
  private statePriority = 0;

  /** 属性混合状态 */
  private scale = 1;
  private blendFrom = 1;
  private blendTo = 1;
  private blendElapsed = 0;
  private blendDuration = 0;

  constructor(clips: HeroClips) {
    this.clips = clips;
    this.player = new FramePlayer(clips.idle);
  }

  get currentState(): HeroState {
    return this.state;
  }

  get scaleValue(): number {
    return this.scale;
  }

  /** 请求切换状态;被更高优先级打断时忽略 */
  request(next: HeroState, priority: number, blend = 0.12): boolean {
    if (priority < this.statePriority && this.state !== next) {
      return false;
    }
    const def = HERO_STATES[next];
    this.state = next;
    this.statePriority = priority;
    this.player.clip = this.clips[def.clip];
    this.player.reset();
    // 属性过渡:从当前值出发,避免跳变
    this.blendFrom = this.scale;
    this.blendTo = def.props?.scale ?? 1;
    this.blendElapsed = 0;
    this.blendDuration = blend;
    return true;
  }

  update(deltaTime: number): void {
    this.player.update(deltaTime);
    if (this.blendDuration > 0 && this.blendElapsed < this.blendDuration) {
      this.blendElapsed += deltaTime;
      const w = Math.min(this.blendElapsed / this.blendDuration, 1);
      this.scale = this.blendFrom + (this.blendTo - this.blendFrom) * w;
    }
  }
}

接线的方式很直白:逻辑层只调 request('hurt', 3),不关心动画怎么播。表现层内部决定是硬切、混合还是打断。物理与输入(第 4、5 章)驱动 request,update 每帧推进,渲染层读 player.frameName 与 scaleValue 去画图。

8.3.7 Cocos 对照:crossFade、AnimationState 与粒子

Cocos Creator 3.8 的状态驱动表现,核心是 Animation 组件的 crossFade(name, duration)——它做的正是我们手写的「过渡混合」:在 duration 秒内把当前动画淡出、目标动画淡入,且以当前播放位置为起点,不会跳变。

// assets/scripts/HeroPresenter.ts
import { _decorator, Component, Animation, AnimationState, Vec3 } from 'cc';
const { ccclass, property } = _decorator;

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

  private priority = 0;

  request(state: string, prio: number, blend = 0.12): boolean {
    if (prio < this.priority) return false;
    this.priority = prio;
    this.anim?.crossFade(state, blend);
    return true;
  }

  onHurt() {
    this.request('hurt', 3, 0.08);
    this.node.setScale(new Vec3(1.1, 1.1, 1));
  }

  onRun() {
    const state: AnimationState | null = this.anim?.getState('run') ?? null;
    if (state) state.speed = 1.4;
    this.request('run', 1);
  }
}

逐个映射到 mini2d:

mini2dCocos Creator 3.8说明
HeroPresenter.requestAnimation.crossFade(name, duration)带过渡的状态切换
statePriority需自行维护引擎不内建优先级,逻辑层自管
player.reset()getState(name) 后 setTime(0)重置播放位置
blendFrom / blendToAnimationState.weight状态权重,引擎自动插值
scaleValue 属性动画tween 或关键帧轨道属性可由 clip 驱动或补间
HERO_STATES 配置表编辑器里的 Animation 状态图可视化配置

几个关键点:

  • crossFade 就是内建的过渡混合,等价于 8.3.4 的权重插值;它内部用 AnimationState.weight 控制每个状态的贡献。AnimationState 的 speed、repeatCount、wrapMode 都能在切换前后设置。
  • 打断在 Cocos 里同样需要逻辑层自己判断优先级——引擎提供混合能力,但「谁该打断谁」是游戏规则,得写在脚本里,这正是我们在 request 里做的。
  • 属性动画在 Cocos 有两条路:一是把它编进 AnimationClip 的属性轨道(关键帧),二是用 8.2 的 tween 在代码里驱动。前者适合固定的、可在编辑器里调的动画,后者适合运行时可变的(如血量条长度)。
  • SkeletalAnimation 继承自 Animation,用于骨骼动画;crossFade、play 等 API 完全一致,区别只在数据来源是骨骼曲线而非逐帧图。2D 项目若用 Spine 或 DragonBones 骨骼资源,走的是同类接口。
  • ParticleSystem2D 是 2D 粒子系统组件,用来做受击火花、跑步尘土这类「非姿态」的表现。它有 play() / stop() / resetSystem() 等控制方法,totalParticles、life、emissionRate、startColor / endColor 等参数。它不是状态机的一部分,但常与状态绑定——hurt 状态触发一次粒子爆发。

一句话总结:mini2d 把「状态表 + 优先级 + 权重混合 + 属性补间」四件事手写在一个 HeroPresenter 里;Cocos 用 Animation.crossFade 提供混合、用 AnimationState 暴露权重与播放控制,优先级与状态表仍由脚本自管,表现粒子则交给 ParticleSystem2D。引擎帮你省掉的是混合的数学与时间管理,省不掉的是「什么时候该切什么状态」这件事——那永远是游戏设计的一部分。

至此,从一格格静态图到会跑会跳会受击的完整表现层就搭好了。下一章我们给游戏加上声音:音频播放、音效池,以及移动端浏览器那套绕不开的音频解锁限制。

阅读导航:上一节:8.2 补间与缓动曲线的数学 · 下一节:9.1 音频播放与音效池 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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