动画与特效落地
3.1 三种动画方案怎么选
我给 Skyline Courier 定了一条规矩:主角用 Spine,其余全用序列帧。
不是 Spine 不好,是 Spine 有代价。Spine 资源导入后需要骨骼数据(.json 或 .skel)、图集纹理(.png)、图集数据(.txt/.atlas)三个文件,而且 Creator 3.8 对应的 Spine 版本是 v3.8。运行时多一套原生模块,微信小游戏那边还要考虑 Wasm 打包模式。我第一版全用 Spine,主包多了 400 KB,换来的是主角动画质量确实高一档。
后来我把主角也换成序列帧了。原因很俗气:主角动画只有待机、飞行、送达三段,每段不超过 12 帧,用序列帧做出来的效果和 Spine 几乎看不出差别,但省下的 400 KB 能多塞两级美术关卡。
选型表我自己用这个:
| 方案 | 适用 | 代价 | 我的用量 |
|---|---|---|---|
| 序列帧 | 帧数少、动作简单、要合批 | 内存占用随帧数线性涨 | 无人机、障碍、UI 动效 |
| Spine | 角色动作多、需要骨骼混合 | 运行时体积 + 制作工时 | 暂未使用 |
| 引擎内置骨骼(SkeletalAnimation) | FBX/glTF 带蒙皮 | 3D 为主,2D 用不上 | 不适用 |
Creator 3.8 内置的 Animation 组件主要适合 UI 动画,官方动画文档说得很清楚:内置动画编辑器适用于制作不太复杂的、需要与逻辑联动的动画,复杂的特效、角色动画、嵌套动画应该考虑 Spine、DragonBones 或 3D 骨骼动画编辑器。
3.2 Animation 组件与 AnimationClip
Animation 组件管理一组动画状态,每个 AnimationClip 对应一个 AnimationState,状态的名字默认就是 clip 的名字。
组件上三个核心属性:clips 是它管理的剪辑数组,defaultClip 是默认剪辑,playOnLoad 决定是否在组件开始运行时自动播放默认剪辑。
有个细节容易忽略:playOnLoad 只在组件开始运行前没调用过 crossFade 或 play() 时才生效。一旦我在 onLoad 里主动播了某个动画,playOnLoad 就作废了。
import { _decorator, Component, Animation, AnimationState } from 'cc';
const { ccclass } = _decorator;
@ccclass('DroneVisual')
export class DroneVisual extends Component {
private _anim!: Animation;
onLoad() {
this._anim = this.getComponent(Animation)!;
}
start() {
// 切到飞行状态,0.15 秒淡入
this._anim.crossFade('fly', 0.15);
}
onDelivery() {
const state = this._anim.getState('fly') as AnimationState;
state.speed = 1.6; // 送达瞬间加速
}
}
play(name) 是立即切换,会停掉正在播的其他动画。crossFade(name, duration) 是平滑切换,默认 0.3 秒。需要注意的是,crossFade 机制下同一时刻可能有不止一个动画状态在播放,所以 Animation 组件没有"当前动画"这个概念,pause()、resume()、stop() 作用的是所有状态。
AnimationState 上常用的几个属性:speed 播放速率、wrapMode 循环方式、time 累计播放时间、duration 单次时长、repeatCount 迭代次数、delay 延迟秒数、current 当前帧进度。改 wrapMode 会重置 time 和 repeatCount,这个副作用要注意。
3.3 状态切换的过渡与打断
我第一版的动画切换是硬切,play('fly') 直接把 play('idle') 顶掉。视觉上就是无人机一顿一顿的,特别难看。
改成 crossFade 之后好很多,但过渡时间我调了很久。0.3 秒太长,无人机从悬停到起飞会有明显的"双重姿态",idle 和 fly 同时在播,机体一半在晃一半在往前倾。我最后用 0.12 到 0.18 秒,看得清切换又不拖沓。
打断是另一个问题。玩家可能在"送达"动画播到一半时又接了新单,这时候要立刻切走。crossFade 本身就会打断上一个过渡,不需要额外处理,但如果你对状态做了额外控制,比如手动改 state.speed,得记得恢复。
我踩过的坑是 playOnLoad 和主动 play 的时序。onLoad 里我调了 play('idle'),组件的 playOnLoad 就是 true,结果 idle 被播了两遍,虽然视觉上没差别,但 play 内部的 _hasBeenPlayed 标记会让状态机判断出错。干脆我只用一种方式:要么勾 playOnLoad 播默认剪辑,要么在代码里播,二选一。
帧事件我也用上了。AnimationClip 的 events 数组里每项是 { frame, func, params },frame 是触发时间点(秒),func 是回调函数名,params 是参数。引擎会在动画根节点的所有组件里搜索同名方法并调用。
送达动画在第 0.5 秒触发一次粒子特效和飘字就是这么做的。注意 func 指向的方法必须挂在动画根节点上,不是挂在别的层级里。
3.4 粒子特效在 2D 俯视角里的用法与代价
2D 粒子用 ParticleSystem2D 组件,资源是 plist 配置文件加图片。组件属性里我实际用到的:
duration 是粒子系统运行时间,单位秒,-1 表示持续发射。emissionRate 是每秒发射的粒子数。life 是粒子运行时间及变化范围。startColor 和 endColor 是颜色。startSize 和 endSize 是大小。totalParticle 是最大粒子数。positionType 有 FREE、RELATIVE、GROUPED 三种,位置类型。emitterMode 有 GRAVITY 和 RADIUS 两种发射器类型。
组件上还有 playOnLoad、autoRemoveOnFinish(播完自动销毁节点)、preview(编辑器里预览)、spriteFrame(粒子贴图)、customMaterial(自定义材质)。
俯视角的取舍是这样的:风向提示我用粒子,因为要表达"这一片区域有横向气流"。撞墙反馈我用粒子,因为要表达碎裂方向。落地扬尘我用粒子。
代价我算过一次。发射器数量控制在 6 个以内,同时存在的粒子总数控制在 200 以内。超过之后低端机上掉帧明显,不是渲染慢,是每个粒子都有 CPU 侧的更新和顶点数据写入。
我最后砍掉的是"每架无人机都挂一个粒子系统"。改成只有风区在预警阶段才启粒子,飞过去的无人机只改 Sprite 的颜色和旋转。这样同屏粒子数从峰值 400 降到 120。
还有一点:2D 粒子的贴图最好单独做一张小图,不要用大图。粒子系统每帧会写顶点数据,贴图尺寸不影响这个开销,但大贴图会占显存。
3.5 自定义材质与特效的表现一致性
3.8 里给 2D 渲染组件加自定义材质,入口是组件上的 CustomMaterial 属性。官方文档明确:2D 渲染对象不支持多材质,自定义材质最多一个;请使用 builtin-spine 或 builtin-sprite 这类 2D 专用 Shader,不要选 3D 组件用的 shader;用了自定义材质之后,组件面板上的 Grayscale 属性会失效,得自己在材质里实现。
还有一条我一开始没当回事:用了自定义材质之后,组件的深度检测信息以材质为准。也就是说,不开深度检测的话,这个 sprite 就不会跟 3D 物体产生遮挡关系了。我的游戏是纯 2D,无所谓;但如果以后加 3D 元素,这就得注意。
import { _decorator, Component, Sprite } from 'cc';
const { ccclass } = _decorator;
@ccclass('HitFlash')
export class HitFlash extends Component {
private _mat: any = null;
start() {
const sprite = this.getComponent(Sprite)!;
this._mat = sprite.customMaterial;
}
setFlash(v: number) {
if (this._mat) {
this._mat.setProperty('flash', v);
}
}
}
自定义 shader 的做法是从内置 effect 复制一份改。资源管理器里 internal -> effects 目录下能看到内置着色器,官方 2D 着色器 Gradient 那篇教程就是拷贝 builtin-sprite 的内容,改 CCEffect 段加 properties,改片元着色器,再建一个 .mtl 材质在 Effect 栏选自己的 effect。
性能上要记住一件事:自定义材质会打断合批。我有三处用了自定义材质,风区渐变、护盾脉动、命中闪白。这三处都是独立节点,数量可控。其余地方坚决不用。
再说表现一致性,这是我踩坑最多的一块。
早期版本里,撞墙有音效有粒子有屏幕抖动,但没有数值反馈,玩家不知道掉了多少耐久。补上飘字之后,同样的事件,玩家的理解完全变了。同理,送达必须有金币音 + 数字跳动 + 短暂慢镜头,这三样缺一样,"成功"的感觉就不成立。
我的自检清单是这样:任何一个玩法事件,问自己四个问题:玩家看到什么、玩家听到什么、屏幕上哪个数字变了、有没有一个 0.1 秒内能感知的细节。四个都答不上来,这个事件就是没做完。
具体到动画资源与图集的配合:序列帧要合到一张图集里,注意序列帧的图集不能开自动裁剪(Trim),否则帧的位置信息会丢。TexturePacker 里 Sprites 分类的 Trim mode 要选Trim,不要选 Crop, flush position。3.x 不支持 TexturePacker 4.x 以下的图集格式,导入会直接报错。
另外自带位置信息的序列帧动画有个坑:这种素材画在较大的画布上,靠画布内的位置变化表现位移。这时候 Sprite 的 Trim 要设为 false,Size Mode 设为 RAW,否则角色会在每帧之间跳动。我第一批序列帧就是这么翻车的。
