Day 05 · 让你的游戏会"动":动画系统从 Clip 到状态机全解
学习目标:掌握 Cocos 动画剪辑、动画组件、AnimationGraph 状态机和 Tween 缓动
预计时间:3 小时
难度:⭐⭐⭐☆☆
Cocos 动画系统全景
动画系统 ├── Animation Clip(动画剪辑)—— 单段动画数据 ├── Animation 组件 —— 播放管理器(简单场景) ├── AnimationGraph(动画图)—— 状态机(复杂角色动画) ├── Skeletal Animation —— 骨骼动画(DragonBones/Spine) └── Tween(缓动)—— 代码驱动的补间动画1. Animation Clip(动画剪辑)
动画剪辑是动画的数据文件,记录了一段时间内节点属性(位置、旋转、透明度等)的变化曲线。
1.1 在编辑器中创建动画剪辑
- 选中要添加动画的节点
- 在 Inspector 中点击添加组件→Animation
- 在 Animation 组件的 Clips 列表中点击+
- 创建新的 Animation Clip 文件
1.2 动画编辑器
打开动画编辑器(窗口 → 动画编辑器):
操作流程:
- 点击"录制"按钮(红点)
- 移动时间轴到 0 秒,设置节点属性(位置/透明度等)→ 自动添加关键帧
- 移动时间轴到 1 秒,修改节点属性 → 自动添加关键帧
- 点击播放预览效果
- 点击"录制"按钮关闭录制模式
可以制作动画的属性:
- 节点的
position、rotation、scale - Sprite 的
color(淡入淡出) - Sprite 的
spriteFrame(帧动画) - UIOpacity 的
opacity - 其他任意数值属性
2. Animation 组件(程序控制)
import{_decorator,Component,Animation,AnimationState}from'cc';const{ccclass,property}=_decorator;@ccclass('AnimationController')exportclassAnimationControllerextendsComponent{private_anim:Animation=null!;onLoad(){this._anim=this.getComponent(Animation)!;}start(){// 播放默认动画(defaultClip)this._anim.play();// 播放指定名称的动画this._anim.play('run');// 暂停this._anim.pause();// 继续this._anim.resume();// 停止this._anim.stop();// 获取动画状态(可以控制速度、时间等)conststate:AnimationState=this._anim.getState('jump')!;state.speed=2;// 2倍速播放state.wrapMode=2;// WrapMode.Loop = 2 循环播放this._anim.play('jump');}// 监听动画事件onLoad2(){this._anim=this.getComponent(Animation)!;// 动画结束事件this._anim.on(Animation.EventType.FINISHED,this.onAnimFinished,this);// 最后一帧事件(循环动画的每次循环结束)this._anim.on(Animation.EventType.LASTFRAME,this.onLastFrame,this);// 停止事件this._anim.on(Animation.EventType.STOP,this.onAnimStop,this);}onAnimFinished(type:Animation.EventType,state:AnimationState){console.log('动画结束:',state.name);// 动画播完后切换到下一个状态if(state.name==='die'){this.node.destroy();}}onLastFrame(type:Animation.EventType,state:AnimationState){console.log('最后一帧:',state.name);}onAnimStop(type:Animation.EventType,state:AnimationState){console.log('动画停止:',state.name);}onDestroy(){this._anim.off(Animation.EventType.FINISHED,this.onAnimFinished,this);this._anim.off(Animation.EventType.LASTFRAME,this.onLastFrame,this);this._anim.off(Animation.EventType.STOP,this.onAnimStop,this);}}3. 帧动画(Sprite 序列帧)
制作人物跑步、爆炸等帧动画:
import{_decorator,Component,Sprite,SpriteFrame,Animation}from'cc';const{ccclass,property}=_decorator;@ccclass('FrameAnimation')exportclassFrameAnimationextendsComponent{// 在 Inspector 中拖入所有帧图片@property([SpriteFrame])walkFrames:SpriteFrame[]=[];@property([SpriteFrame])runFrames:SpriteFrame[]=[];@property({displayName:'帧率',min:1,max:60})fps:number=12;private_sprite:Sprite=null!;private_currentFrames:SpriteFrame[]=[];private_frameIndex:number=0;private_timer:number=0;onLoad(){this._sprite=this.getComponent(Sprite)!;}playWalk(){this._currentFrames=this.walkFrames;this._frameIndex=0;}playRun(){this._currentFrames=this.runFrames;this._frameIndex=0;}update(deltaTime:number){if(this._currentFrames.length===0)return;this._timer+=deltaTime;constframeDuration=1/this.fps;if(this._timer>=frameDuration){this._timer-=frameDuration;this._frameIndex=(this._frameIndex+1)%this._currentFrames.length;this._sprite.spriteFrame=this._currentFrames[this._frameIndex];}}}4. AnimationGraph(动画状态机)
对于复杂角色(有 idle/walk/run/jump/die 多种状态),应使用AnimationGraph。
4.1 创建 AnimationGraph
- 资源管理器右键 →新建→AnimationGraph
- 双击打开 AnimationGraph 编辑器
4.2 状态机设计
Entry → [Idle] ←→ [Walk] ←→ [Run] ↓ [Jump] → [Fall] → [Land] → [Idle] ↓ [Die]4.3 AnimationController 脚本
import{_decorator,Component,AnimationController}from'cc';const{ccclass,property}=_decorator;@ccclass('CharacterAnimator')exportclassCharacterAnimatorextendsComponent{private_animCtrl:AnimationController=null!;// AnimationGraph 中定义的变量名privatereadonlyPARAM_SPEED='speed';// Float:移动速度privatereadonlyPARAM_IS_JUMP='isJump';// Boolean:是否在空中privatereadonlyPARAM_IS_DEAD='isDead';// Boolean:是否死亡privatereadonlyTRIGGER_ATTACK='attack';// Trigger:攻击触发器onLoad(){this._animCtrl=this.getComponent(AnimationController)!;}// 更新移动速度(用于 Idle <-> Walk <-> Run 切换)setMoveSpeed(speed:number){this._animCtrl.setValue(this.PARAM_SPEED,speed);}// 跳跃状态setJumping(isJump:boolean){this._animCtrl.setValue(this.PARAM_IS_JUMP,isJump);}// 死亡die(){this._animCtrl.setValue(this.PARAM_IS_DEAD,true);}// 触发攻击动画triggerAttack(){this._animCtrl.setValue(this.TRIGGER_ATTACK,true);}// 在 AnimationGraph 转换条件中配置:// Idle → Walk:speed > 10// Walk → Run:speed > 150// Run → Walk:speed < 150// Walk → Idle:speed < 10// Any → Jump:isJump == true// Jump → Idle:isJump == false// Any → Die:isDead == true}4.4 在编辑器中配置 AnimationGraph
- 选中角色节点 →添加组件→AnimationController
- 将 AnimationGraph 文件拖入 Graph 槽
5. Tween(代码驱动缓动动画)
Tween 是纯代码控制的补间动画,无需动画文件,非常适合 UI 动画:
import{_decorator,Component,Node,tween,Vec3,UIOpacity,Easing}from'cc';const{ccclass}=_decorator;@ccclass('TweenDemo')exportclassTweenDemoextendsComponent{start(){this.playEntrance();}// UI 入场动画:从屏幕外飞入playEntrance(){conststartPos=newVec3(0,-800,0);constendPos=newVec3(0,0,0);this.node.setPosition(startPos);tween(this.node).to(0.5,{position:endPos},{easing:'backOut',// 弹出效果onUpdate:(target,ratio)=>{// ratio: 0~1,当前进度}}).call(()=>{console.log('入场动画完成');}).start();}// 弹跳缩放动画(强调效果)playPunchScale(){tween(this.node).to(0.1,{scale:newVec3(1.3,1.3,1)}).to(0.1,{scale:newVec3(0.9,0.9,1)}).to(0.05,{scale:newVec3(1.1,1.1,1)}).to(0.05,{scale:newVec3(1,1,1)}).start();}// 淡出销毁fadeOutAndDestroy(){constopacity=this.node.getComponent(UIOpacity)!;tween(opacity).to(0.3,{opacity:0}).call(()=>{this.node.destroy();}).start();}// 循环动画:悬浮效果playHoverLoop(){constoriginalY=this.node.position.y;tween(this.node).to(1.0,{position:newVec3(0,originalY+20,0)},{easing:'sineInOut'}).to(1.0,{position:newVec3(0,originalY-20,0)},{easing:'sineInOut'}).union()// 将上面两个 tween 合并为一个.repeatForever()// 无限循环.start();}// 序列动画(多个动画依次执行)playSequence(){tween(this.node).delay(0.5)// 延迟 0.5 秒.to(0.3,{scale:newVec3(1.2,1.2,1)}).to(0.3,{scale:newVec3(1,1,1)}).delay(1).call(()=>console.log('序列完成')).start();}// 停止所有 TweenstopAllTweens(){this.node.stopAllActions();// 停止该节点的所有 tween// 或:tween(this.node).stop();}}5.1 常用缓动曲线
图片源自 http://hosted.zeh.com.br/tweener/docs/en-us/
| 缓动名 | 效果 | 适用场景 |
|---|---|---|
linear | 匀速 | 匀速移动 |
sineInOut | 平滑开始和结束 | 悬浮、摇摆 |
backOut | 超出后回弹 | UI 弹出 |
bounceOut | 弹跳结束 | 落地、弹跳 |
elasticOut | 弹簧效果 | 强调弹出 |
expoIn | 极慢后极快 | 冲刺加速 |
expoOut | 极快后极慢 | 刹车减速 |
6. 实战:制作得分弹出动画
你接下来在编辑器里做 3 步就行:
给 enemy 节点挂组件:把 Enemy(assets/Enemy.ts)挂到你的敌人节点上
给按钮挂组件:把 GameOverClick 挂到按钮节点(或任意节点)上,并在属性里把 enemy 槽位拖拽绑定到那个敌人节点
绑定按钮点击事件:Button 组件的 ClickEvents 里选 GameOverClick 所在节点,方法选 justClick
import{_decorator,Component,Node}from'cc';import{ScorePopup}from'./ScorePopup';const{ccclass,property}=_decorator;@ccclass('Enemy')exportclassEnemyextendsComponent{@property({displayName:'击败得分',min:0})score:number=100;@property({displayName:'飘字父节点(UI/Canvas等)'})popupParent:Node|null=null;/** * 在“敌人被击败”的那一刻调用它。 * 你可以在碰撞/受伤/HP归零处调用 defeat()。 */defeat(){constparent=this.popupParent??this.node.parent;if(parent){constp=this.node.worldPosition;ScorePopup.show(parent,this.score,p.x,p.y);}this.node.destroy();}}import{_decorator,Component,Node}from'cc';import{Enemy}from'./Enemy';const{ccclass,property}=_decorator;@ccclass('GameOverClick')exportclassGameOverClickextendsComponent{@property({type:Node,displayName:'要模拟击败的敌人'})enemy:Node=null!;start(){}update(deltaTime:number){}justClick(){constenemyNode=this.enemy;if(!enemyNode)return;constenemy=enemyNode.getComponent(Enemy);enemy?.defeat();}}import{_decorator,Component,Label,Node,tween,UIOpacity,UITransform,Vec3}from'cc';const{ccclass,property}=_decorator;@ccclass('ScorePopup')exportclassScorePopupextendsComponent{// 调用此方法在指定位置弹出得分文字staticshow(parent:Node,score:number,worldX:number,worldY:number){// 动态创建 Label 节点constpopupNode=newNode('ScorePopup');parent.addChild(popupNode);constworldPos=newVec3(worldX,worldY,0);constparentUITransform=parent.getComponent(UITransform);if(parentUITransform){// UI 节点下更推荐用本地坐标,避免与 position tween 混用坐标系constlocalPos=parentUITransform.convertToNodeSpaceAR(worldPos);popupNode.setPosition(localPos);}else{popupNode.setWorldPosition(worldPos);}constlabel=popupNode.addComponent(Label);label.string=`+${score}`;label.fontSize=36;label.color.fromHEX('#FFD700');// 金色constopacity=popupNode.addComponent(UIOpacity);// 动画:向上飘+淡出tween(popupNode).by(0.8,{position:newVec3(0,80,0)},{easing:'expoOut'}).start();tween(opacity).delay(0.3).to(0.5,{opacity:0}).call(()=>popupNode.destroy()).start();}// 使用方式:// ScorePopup.show(this.node.parent!, 100, enemy.worldPosition.x, enemy.worldPosition.y);}7. 今日总结
- ✅ 理解动画系统全景:Clip / Animation 组件 / AnimationGraph / Tween
- ✅ 掌握动画编辑器:录制关键帧、编辑曲线
- ✅ 掌握帧动画(序列帧)的代码实现
- ✅ 学会 AnimationGraph 状态机设计与脚本控制
- ✅ 掌握 Tween API 和常用缓动效果
- ✅ 实战:得分弹出动画
⚠️ 动画常见坑
| 问题 | 解决方案 |
|---|---|
| Tween 动画中途停不下来 | 节点销毁前调用node.stopAllActions() |
| AnimationGraph 状态不切换 | 检查转换条件的参数名是否和代码中 setValue 一致 |
| 帧动画闪烁 | fps设置太高,或 SpriteFrame 未打包图集(Atlas) |
| 动画结束事件没触发 | wrapMode 设置为 Loop 时不会触发 FINISHED,用 LASTFRAME |
← Day 04 | 系列目录 | Day 06 →