with one click
cocos-creator-tween-anim
写 Cocos Creator 动效/动画时使用。tween、Animation、Spine、性能与清理。
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
写 Cocos Creator 动效/动画时使用。tween、Animation、Spine、性能与清理。
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Based on SOC occupation classification
做 Cocos Creator 多机型/多分辨率适配时使用。Canvas、Widget、安全区。
Cocos Creator 用 AssetBundle 做分包/远程资源时使用。加载、释放、依赖、缓存。
优化 Cocos Creator 渲染性能时使用。合批、图集、动静分离、Label。
给 Cocos Creator 原生包做热更新时使用。version manifest、增量、校验、回滚。
做 Cocos Creator 大量条目列表时使用。虚拟列表、节点复用。
写 Cocos Creator(TypeScript)时使用。组件、节点、prefab、事件、资源管理规范。
| name | cocos-creator-tween-anim |
| description | 写 Cocos Creator 动效/动画时使用。tween、Animation、Spine、性能与清理。 |
| category | gamedev |
| tags | ["cocoscreator","动画","tween"] |
tween 做 UI 弹出、飞金币、按钮点击反馈等动效时。Animation 组件播放帧动画,或在动画结束时触发游戏逻辑时。规则: UI 动效优先用 tween(node).to(...).call(...).start() 链式写法;节点销毁、场景切换、或主动取消动效时,必须调用 Tween.stopAllByTarget(node) 停止该节点上所有正在运行的 tween;不要把 tween 的 .call() 回调里对节点的访问放在延迟后,而不先判断节点有效性。
为什么: 这是 Creator 项目里最高频的运行时报错来源之一。典型场景:UI 弹窗弹出动画还没播完(0.3s tween),玩家点了关闭按钮,弹窗节点被 destroy,0.3s 后 tween 的 .call() 回调执行——此时 this 或捕获的节点引用已销毁,访问任何属性都抛出 Cannot read properties of null,且错误栈指向 tween 引擎内部,极难定位。另一个常见错误:节点池归还时没有停止节点上的 tween,下次取出时旧 tween 仍在运行,与新 tween 叠加,节点产生错乱的双重动画。
怎么做:
tween(this.node).to(0.3, { scale: new Vec3(1,1,1) }, { easing: 'backOut' }).call(() => { this.onShowComplete(); }).start();onDestroy() { Tween.stopAllByTarget(this.node); }unuse):Tween.stopAllByTarget(this.node);if (!this.isValid) return;,防止节点已销毁后回调被延迟触发。this._showTween = tween(node).to(...).start();,取消时 this._showTween?.stop();。规则: 帧动画和骨骼动画的时机逻辑(播放完毕后跳转状态、攻击动画命中帧触发伤害)用 Animation 的帧事件(AnimationEvent)或 finished 事件回调触发,不用 scheduleOnce(callback, duration) 硬编码时长。
为什么: scheduleOnce(this.onAttackHit, 0.35) 是新手最常写的"动画同步"方案。问题一:动画时长一旦被美术调整(0.35s 改 0.4s),程序代码里的数字忘了改,命中判定就永远提前或延后。问题二:设备帧率波动导致动画实际播放时长和计时器有微小偏差,在帧率不稳定的低端机上累积误差很明显。问题三:两处(动画文件和代码)维护同一个时间点,必然随着版本迭代出现不同步。帧事件直接嵌入动画文件,时机和动画帧绑定,美术改动画时机会自然跟随,不需要程序同步修改代码。
怎么做:
onHitFrame。onHitFrame() { this._combatSystem.applyDamage(this._target); }。this._anim.on(Animation.EventType.FINISHED, this.onAnimFinished, this);,在 onDestroy 里 off。this._anim.play('attack'); — 状态机切换时用 crossFade 避免切换时的一帧跳变。规则: 同一角色的多个实例必须共享同一份 SkeletonData 资源(通过 @property 引用或动态加载后 addRef 复用),不每次 instantiate 时重复加载;移动端单个 Spine 角色控制在 60 骨骼以内,换装时优先用 Attachment 替换而非多 SkeletonComponent 叠加。
为什么: Spine 渲染是 Creator 项目里 GPU 压力最集中的地方。SkeletonData 包含骨架定义、所有动画曲线、皮肤数据,体积可达数 MB;如果每个敌人实例各自走一遍 resources.load 加载 SkeletonData,10 个敌人就是 10 份重复数据驻留内存,纹理内存直接翻倍。换装方案的性能陷阱更隐蔽:用多个 SkeletonComponent 叠加(一层身体+一层装备)的方案每层产生独立 Draw Call,且每层都有自己的骨骼运算,5 个角色就是 10 次骨骼更新;而用同一套骨骼的 Attachment 换装,只有 1 次骨骼运算,装备替换通过贴图槽替换,不增加 Draw Call 也不增加骨骼数。
怎么做:
SpawnManager 里预加载并缓存 SkeletonData:resources.load('spine/hero', sp.SkeletonData, (err, sd) => { sd.addRef(); this._heroSkeletonData = sd; })。instantiate 时将缓存的 SkeletonData 赋给新节点:node.getComponent(sp.Skeleton).skeletonData = this._heroSkeletonData;。skeleton.setAttachment('weapon_slot', 'sword_attachment'); — 仅替换对应骨骼槽的 Attachment,无额外 Draw Call。this._heroSkeletonData?.decRef(); this._heroSkeletonData = null;。规则: 所有 tween 动效必须指定 easing 参数(如 backOut、quartOut、elasticOut),不使用默认线性缓动;动效时长以秒为单位,不依赖帧数(不写 duration = targetFrames / 60);动效参数(时长、幅度)集中定义在配置常量里,不在代码中散落魔法数字。
为什么: 线性动效是"AI 写的动画"最直观的特征——位移、缩放匀速变化,没有加速减速,机械感很强,玩家直觉上感受到"不对劲"但说不清楚。easing: 'backOut' 让按钮弹出时有一个超过目标值再回弹的手感,elasticOut 给弹窗增加果冻感,这些曲线把"感觉有生命力"和"机械感"区分开来,是动效品质的核心。帧数依赖的问题:duration = 18 / 60 = 0.3s 在 60fps 设备上正常,但代码的语义是"18帧"而不是"0.3秒",一旦逻辑被移植或时间计算方式改变,这个数字就失去了含义,且不同帧率设备上 tween 时长本身是固定的(tween 基于真实时间),所以直接写秒数更清晰。
怎么做:
const ANIM = { POPUP_DURATION: 0.25, POPUP_EASING: 'backOut', COIN_FLY_DURATION: 0.6 } as const;tween(panel).to(ANIM.POPUP_DURATION, { scale: new Vec3(1,1,1) }, { easing: ANIM.POPUP_EASING }).start();tween(btn).to(0.08, { scale: new Vec3(0.9,0.9,1) }).to(0.12, { scale: new Vec3(1,1,1) }, { easing: 'backOut' }).start();.to(...).to(...).call(...),比多个 scheduleOnce 更易维护和取消。dt 时间步,应自动适配)。规则: 组件的 onDestroy 里必须:① Tween.stopAllByTarget(this.node) 停止所有 tween;② this._anim.off(...) 注销所有 Animation 事件监听;③ 取消所有 scheduleOnce/schedule 调度;④ 如果持有 Spine 的 SkeletonData 引用,调用 decRef()。不做这些清理,节点销毁后回调仍会执行,轻则控制台报错,重则访问已释放内存导致不可复现的随机崩溃。
为什么: Creator 的 tween 系统是全局单例管理的,tween(node).start() 把任务注册到全局 TweenManager,节点 destroy 后 TweenManager 并不自动感知,仍然每帧 tick 这个 tween,直到 tween 完成并执行回调——此时节点已销毁,回调里的 this.xxx 全部是访问已释放的引用。Animation 事件监听器同样如此:anim.on(FINISHED, handler, this) 如果没有 off,即便 this 所在节点销毁,只要 Animation 组件的宿主节点还存在,事件依然会尝试回调 handler,this 已是废指针。这类 bug 极难复现——只在"动效播放到一半销毁节点"这个特定时序下触发,稳定性测试很难覆盖。
怎么做:
onDestroy() {
// ① 停止该节点上所有 tween
Tween.stopAllByTarget(this.node);
// ② 注销 Animation 事件
if (this._anim) {
this._anim.off(Animation.EventType.FINISHED, this.onAnimFinished, this);
}
// ③ 取消调度器(若用了 schedule/scheduleOnce)
this.unscheduleAllCallbacks();
// ④ 释放 Spine SkeletonData 引用计数
if (this._skeletonData) {
this._skeletonData.decRef();
this._skeletonData = null;
}
}
if (!this.isValid || !this.node.isValid) return;。unuse() { Tween.stopAllByTarget(this.node); this._anim?.stop(); }stopAllByTarget 足以取消整个链,不需要逐段停止。// 反例 — 弹窗关闭时没停 tween,回调访问已销毁的 this
import { _decorator, Component, tween, Vec3 } from 'cc';
const { ccclass } = _decorator;
@ccclass('PopupPanel')
export class PopupPanel extends Component {
show() {
// ❌ 没有保存 tween 引用,也没有 stopAllByTarget,关闭时无法取消
tween(this.node)
.to(0.3, { scale: new Vec3(1.2, 1.2, 1) })
.to(0.15, { scale: new Vec3(1, 1, 1) })
.call(() => {
// ❌ 如果 0.45s 内节点被 destroy,这里报错
this.onShowComplete();
})
.start();
}
hide() {
// ❌ 没有 Tween.stopAllByTarget,show 的 tween 仍在运行
this.node.destroy();
}
onAttack() {
this._anim.play('attack');
// ❌ 硬编码 0.35s,美术改动画后这里忘了同步
this.scheduleOnce(() => { this._hitEffect.active = true; }, 0.35);
}
// ❌ 没有 onDestroy,tween 和 Animation 事件不清理
}
// 正例 — tween 正确停止,帧事件驱动动画时机,onDestroy 完整清理
import { _decorator, Component, tween, Tween, Vec3, Animation, sp } from 'cc';
const { ccclass, property } = _decorator;
const POPUP = { SHOW_DURATION: 0.3, EASING: 'backOut' } as const;
@ccclass('PopupPanel')
export class PopupPanel extends Component {
@property(Animation) private _anim: Animation = null!;
private _showTween: ReturnType<typeof tween> | null = null;
onLoad() {
// ✅ 动画结束事件用组件监听,onDestroy 里注销
this._anim.on(Animation.EventType.FINISHED, this.onAnimFinished, this);
}
show() {
Tween.stopAllByTarget(this.node); // ✅ 先停掉旧的,防止叠加
this._showTween = tween(this.node)
.set({ scale: new Vec3(0.5, 0.5, 1) })
.to(POPUP.SHOW_DURATION, { scale: new Vec3(1, 1, 1) }, { easing: POPUP.EASING })
.call(() => {
if (!this.isValid) return; // ✅ 回调前检查节点有效性
this.onShowComplete();
})
.start();
}
hide() {
Tween.stopAllByTarget(this.node); // ✅ 销毁前停止所有 tween
tween(this.node)
.to(0.15, { scale: new Vec3(0, 0, 1) })
.call(() => {
if (!this.isValid) return;
this.node.active = false; // ✅ 隐藏而非 destroy,支持节点池复用
})
.start();
}
// ✅ 命中帧逻辑由动画帧事件触发,不写死时长
onHitFrame() {
if (!this.isValid) return;
this._hitEffect.active = true;
}
private onAnimFinished(type: Animation.EventType, state: AnimationState) {
if (!this.isValid) return;
if (state.name === 'attack') {
this._hitEffect.active = false;
}
}
onDestroy() {
Tween.stopAllByTarget(this.node); // ✅ 停所有 tween
this._anim?.off(Animation.EventType.FINISHED, this.onAnimFinished, this); // ✅ 注销事件
this.unscheduleAllCallbacks(); // ✅ 清调度器
}
}
// 反例 — 每个敌人各自加载 SkeletonData,线性 tween 无手感
@ccclass('EnemySpawner')
export class EnemySpawner extends Component {
spawnEnemy(pos: Vec3) {
const enemy = instantiate(this.enemyPrefab);
// ❌ 每个敌人各自 load,10 个敌人 = 10 份 SkeletonData 内存
resources.load('spine/enemy', sp.SkeletonData, (err, sd) => {
enemy.getComponent(sp.Skeleton)!.skeletonData = sd;
// ❌ 默认线性缓动,弹出感机械
tween(enemy).to(0.3, { position: pos }).start();
});
this.node.addChild(enemy);
}
}
// 正例 — 预加载共享 SkeletonData + easing 曲线赋予手感
@ccclass('EnemySpawner')
export class EnemySpawner extends Component {
@property(sp.SkeletonData) enemySkeletonData: sp.SkeletonData = null!;
// ✅ Inspector 直接引用或统一在 ResourceManager 里预加载,所有实例共享
onLoad() {
// ✅ 手动 addRef 延长生命周期,onDestroy 里 decRef
this.enemySkeletonData.addRef();
}
spawnEnemy(spawnPos: Vec3, targetPos: Vec3) {
const enemy = instantiate(this.enemyPrefab);
// ✅ 共享同一份 SkeletonData,不重复加载
enemy.getComponent(sp.Skeleton)!.skeletonData = this.enemySkeletonData;
enemy.setPosition(spawnPos);
this.node.addChild(enemy);
// ✅ backOut easing 赋予弹入手感,时长用具名常量
tween(enemy)
.to(0.35, { position: targetPos }, { easing: 'backOut' })
.start();
}
onDestroy() {
Tween.stopAllByTarget(this.node);
this.enemySkeletonData?.decRef(); // ✅ 释放引用计数
}
}
tween(...).start() 的节点在 onDestroy 或归还对象池的 unuse 里调用了 Tween.stopAllByTarget(node)。.call() 回调内第一行有 if (!this.isValid) return; 或 if (!node.isValid) return; 守卫。FINISHED 事件,没有 scheduleOnce 硬编码时长。Animation.EventType.FINISHED 等动画事件在 onDestroy 里调用了 off 注销。SkeletonData 同场景内所有实例共享同一份引用,没有每个实例各自加载。easing 参数,没有使用默认线性缓动。