所以它现在长这样

屏幕捕获_2026_09_24_18_33_40_325
我把这整套流程搬进了一个时间轴面板:

选中节点 → 加一条属性轨道 → 在轨道上点一下就是一个关键帧 → 按住菱形拖时间(自动吸附 0.05 秒)→ 右上角浮窗里改数值和缓动 → 按空格直接在场景里看。

每个轨道里会画出这段的缓动曲线,弹过头没弹过头一眼就看得见。看不下去了就按住播放头左右拖,场景里的节点会跟着对位到那一帧。

顺带说一句:预览的时候它不改你的场景——停下来或者点到别的节点,属性会自己还原回去。

调满意了,点一下导出:

  • 要么给你一个 play(node) 函数,复制粘贴进自己的脚本就能调;
  • 要么直接给你一个完整组件类,保存到 assets/scripts/tween_anim/,顺手就挂到节点上了。

导出来的就是普通的 cc.tween 代码,没有任何自定义运行时,import 里只有 cc。哪天不用这个工具了,把插件删掉,代码照旧能用。


做这个东西,我踩过的坑(这才是重点)

如果只是"做个可视化界面",这事一周就完了。真正花时间的是下面这些——它们不是我的 bug,是引擎本身的脾气,我觉得值得单独讲一遍。

坑 1:循环第二遍,它不动了

tween(node).to(1, { position: xxx }).repeatForever() —— 你以为是循环,实际是"播一遍,然后第二遍原地杵着"。

原因:to() 的起点不是你在代码里写的那个值,而是动作开始那一刻节点的当前值。第一遍播完,节点已经在终点了;第二遍开始,起点=终点,所以不动。

解法:每一遍开头先 .set(首帧值) 把起点锚死。瞬时动作在序列里会立即执行,被 Repeat 包住时每遍都会重跑一次——循环这才成立。

(我甚至专门写了个反向测试:把这一行删掉,比对必须能抓到动画"跑飞",抓不到就是测试有问题。)

坑 2:repeat(3) 是"再播 3 次"还是"一共 3 遍"?

这个只能翻源码。Repeat.isDone() 里写的是 _total === _times,所以是一共 3 遍。填 1 等于不循环。

顺便说,repeat 里还有个历史坑:文档专门标注了"此前语义是'重复几次',请及时适配"。如果你从老项目拿过来的代码,这里很可能一直少播了一轮。

坑 3:想让动画"上去再回来",引擎给的两个函数都不好使

第一个念头肯定是 reverse() 或者 reverseTime()。实测:

  • reverse() 遇到绝对值的 to(),直接返回一个空动作,再给你一条 warn(16382)——只有相对 by() 能反转;
  • reverseTime() 的源码注释是它自己写的:"Use this action carefully. This action is not sequenceable." 放进序列里它会重新捕获起始值,结果就是"倒放不动"。

所以"往返"这件事,最后还是老老实实按关键帧镜像生成一遍反向链:段序倒过来、首末值互换、缓动取镜像(In ↔ Out 互换,InOut 自己对称,自定义贝塞尔按 [1-x2, 1-y2, 1-x1, 1-y1] 翻)。这样出来的才是真的"时间倒流",跟预览里看到的完全对得上。

坑 4:往返和循环,有时候看起来一模一样(这不是 bug)

这条建议所有做动画的人都记一下。

如果你的动画首尾同值,比如 0 → 255 → 0(上去再回来):

  • 循环 = 上去、回来、上去、回来……
  • 往返 = 上去、回来、回来、上去……

等等,第二段是"倒放回来的过程"——而"上去再回来"这段动画本身倒过来放,就是"上去再回来"。所以:

首尾同值的动画,往返和循环本来就该长得一样。 你没法从它身上看出区别,因为数学上就没区别。

想直观看出区别,得用单向动画:0 → 255 一段,循环是"到顶瞬间跳回 0 再往上",往返是"到顶后原路退回 0"。

这个坑我一开始也怀疑是自己写错了,最后是拿逐帧数据比对出来的。

坑 5:反复导出,节点上挂了一堆重复组件

脚本模式导出后会顺手把组件挂到节点上。早期版本每导出一次就多挂一个——因为当时是靠编辑器返回的节点信息去判断"有没有挂过",而那个结构里的组件描述跟我按类名比对的格式根本不一样,永远比对不上。

现在的做法:在场景进程里用引擎自己的 API(js.getClassByName + node.getComponents)精确判断,已经有了就只保留一个,历史上挂出来的重复副本也一起收敛掉。导出结果会明确告诉你"已保存并挂载"还是"节点上已有,没重复挂"。

坑 6:预览和导出,差一帧也算不一致

本来我以为"预览我自己插值、导出走引擎 tween",两边看起来差不多就行。后来发现不行:

起播延时期间,我的预览会强行把首帧写下(因为不写就是上一帧的残影),而导出的 .delay(d) 到点前一个属性都不写。

这种差异肉眼几乎看不出来,但它就是"预览所见 ≠ 导出所得"。改法是让预览也遵守"延时期间一个属性都不写"。现在我拿一份逐帧比对的测试盯着这件事:五种播放模式、有限次数循环、组件模式,生成的代码在引擎里跑出来的值,必须和预览映射出来的值对得上。

坑 7:写进去的"透明度"和"颜色",其实啥也没干

这个是最近才发现的,也顺手提醒一下:

  • 引擎 3.8 的 Node 上没有 opacity,也没有 color(只有 angle)。
  • 而 tween 写到不存在的属性时,是静默跳过的(源码里就一句 if (_t === undefined) continue;),连个警告都不给。

也就是说,tween(node).to(1, { opacity: 0 }) 这种写法,看着像在工作,其实什么都没发生。UI 的透明度要用 UIOpacity 组件,颜色要用渲染组件(Sprite / Label 之类)。

所以:如果你写的 tween 里有 opacity / color,建议今天就去确认一下它到底生效了没有。

坑 8:存下来的动画,重开编辑器就跟节点"对不上"了

这个坑最隐蔽,因为它第一次用根本不会遇到,第二次才会。

我原本以为场景里节点的 uuid 是稳定的——存进动画里,下次打开照着 id 找回来就行。直到我拿一个真实存档去比对:里面记的两个 id,在 .scene 文件里一个都搜不到。

原因是:节点 uuid 只在那一次编辑器会话里稳定。节点如果没跟着场景保存(或者你的节点是从 prefab 实例化出来的),重开编辑器就会换一批 id,存档里记的老 id 自然谁都不是。

光靠 uuid 存动画,等于把钥匙留在了上一班车上。现在的做法是三级对账:先按 uuid 找,找不到就用层级路径(Canvas/ui_主标题文字 这种),再退到唯一的节点名;名字重名、或者节点真的没了,就明确告诉你"哪个节点在场景里找不到"——绝不悄悄把动画套到别的节点上(那比报错可怕多了)。

顺手记一句:想让 id 稳定,把节点随场景保存一下再重开。

坑 9:一个 select 调用,把"选中"变成了"多选"

这条跟 tween 无关,但坑的机制一模一样——API 名看着是人话,语义不是你想的那个。

我的编辑器扩展里,面板点某个节点时希望编辑器也选中它,于是调了场景的选中接口。结果点几下之后,编辑器里变成了多选:拖 gizmo 一动,前面点过的节点全跟着跑。

原因是那个 select 是追加语义:它不清空、只往选中集合里加。更糟的是我自己加的"保险"还放大了它——我先用"选中集合里包含目标节点"当成功判据,判不过就换个通路再调一次,于是同一个节点被加了三遍,之前的选中也一直留着。

修法两条:取消选中类接口(clear / unselect)才可以重试,select 每次只准调一次;判据改成"选中集合去重后只剩它一个",只查"包含"会放过多选。清不干净就如实报失败,别假装成功。


一句话总结这段时间的收获

tween 本身不难,难的是这些"看着对、其实没生效"的细节,它们藏在源码注释、isDone() 的等式、和一句 continue 里。

我把这些东西连同时间轴一起,做成了一个 Cocos Creator 扩展(YYKA Tween),自己每天在用。它只干一件事:让节点动画看得见地调、导出来就能用。

也顺便说清楚边界,免得你以为它能包办一切:

  • 骨骼动画、序列帧、多动画混合——不做,也不打算做;
  • 目前最靠谱的是位置 / 缩放 / 旋转;透明度和颜色的轨道还在补组件驱动(就是坑 7 那件事);
  • 自定义组件上的属性(比如 Sprite.fillRange)还没开放;
  • 只支持 Cocos Creator 3.8.8 及以上。

最后,如果你也被 tween 的某个细节坑过——不管是往返、repeat 的语义,还是某个"写了没反应"的属性——欢迎在后台跟我聊两句,说不定下一版就把它堵上了。