一键导入
cocos-creator-tween-anim
写 Cocos Creator 动效/动画时使用。tween、Animation、Spine、性能与清理。
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
写 Cocos Creator 动效/动画时使用。tween、Animation、Spine、性能与清理。
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
做 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 参数,没有使用默认线性缓动。