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;
}
四态的典型语义:
| 状态 | 帧动画 | 循环 | 优先级 | 备注 |
|---|---|---|---|---|
idle | idle 序列 | 循环 | 0 | 呼吸、眨眼,最低优先级 |
run | run 序列 | 循环 | 1 | 播放速度跟移动速度挂钩 |
jump | jump 序列 | 单次 | 2 | 落地后回落 |
hurt | hurt 序列 | 单次 | 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)发生在「正在过渡时又来了新转移」。规则很简单:
- 新转移的
priority大于当前状态优先级 → 允许打断,从当前混合进度重新开始一段新过渡。 - 否则忽略,等当前过渡结束。
有一处细节容易踩坑:打断时不能把 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:
| mini2d | Cocos Creator 3.8 | 说明 |
|---|---|---|
HeroPresenter.request | Animation.crossFade(name, duration) | 带过渡的状态切换 |
statePriority | 需自行维护 | 引擎不内建优先级,逻辑层自管 |
player.reset() | getState(name) 后 setTime(0) | 重置播放位置 |
blendFrom / blendTo | AnimationState.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 音频播放与音效池 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。