所以它现在长这样

我把这整套流程搬进了一个时间轴面板:
选中节点 → 加一条属性轨道 → 在轨道上点一下就是一个关键帧 → 按住菱形拖时间(自动吸附 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 的语义,还是某个"写了没反应"的属性——欢迎在后台跟我聊两句,说不定下一版就把它堵上了。