9.2 Tween 与程序动画
9.1 节我们把角色的跑、跳、受击交给了 Animation 编辑器,因为它们是多属性协同的姿态。但《方块冲刺》里还有一大批动效,性质完全不同:金币原地旋转、道具拾取时闪一下、弹窗从屏幕外弹进来、分数数字跳动。这些动效的共同点是——只有一个数在变。
给单属性动效开一次编辑器,成本高得离谱:要进动画编辑模式、建剪辑、挂到组件、再回来写代码触发。Cocos 给这类需求准备了 tween:一行代码就能让一个属性在指定时间内从 A 平滑变到 B,曲线还能随手调。
9.2.1 tween 的三层模型
理解 tween 只要记住三层:
| 层 | 是什么 | 例子 |
|---|---|---|
| 目标 | 要被改的对象 | 一个 Node、一个 UIOpacity 组件、任意自定义对象 |
| 动作 | 对目标做什么 | to(变到某值)、by(变多少)、delay、call |
| 控制 | 何时开始、怎么组合 | start、stop、sequence、parallel、union |
一个完整的 tween 就是「选目标 → 串动作 → start()」。注意最后一步:不调 start() 的 tween 不会执行,这是最容易漏的一行。
9.2.2 第一个 tween:让金币转起来
先看最短的可用例子。金币是一个 Sprite 节点,我们希望它一直转:
import { _decorator, Component, Node, Vec3, tween } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('CoinSpin')
export class CoinSpin extends Component {
@property(Node)
private visual: Node | null = null;
protected onLoad(): void {
const node = this.visual ?? this.node;
tween(node)
.by(0.8, { angle: 360 })
.repeatForever()
.start();
}
}
by(0.8, { angle: 360 }) 的意思是「在 0.8 秒内,让 angle 相对当前值增加 360 度」。repeatForever() 让这一串动作无限循环。angle 是 Node 上现成的属性(单位是度),不用自己算弧度。
如果想让金币「边转边缩放」,把两个动作塞进 parallel:
tween(node)
.parallel(
tween().by(0.8, { angle: 360 }),
tween().by(0.4, { scale: new Vec3(1.2, 1.2, 1) }).by(0.4, { scale: new Vec3(-1.2, -1.2, 0) }),
)
.repeatForever()
.start();
注意内层的 tween() 不传目标——它继承外层 tween(node) 的目标。parallel 让两条动作同时跑:旋转 0.8 秒一圈,缩放 0.4 秒放大再 0.4 秒缩回,一个周期都是 0.8 秒,正好对上。
9.2.3 动作链:to / by / delay / call
to 和 by 是两条最常用的动作,区别只有一处:
| 动作 | 含义 | 例子 | 结果 |
|---|---|---|---|
to | 变到绝对值 | to(0.3, { angle: 90 }) | 无论起点多少,终点都是 90 |
by | 变相对量 | by(0.3, { angle: 90 }) | 在当前值上加 90 |
循环动画必须用 by:如果用 to(0.8, { angle: 360 }) 配 repeatForever,第二次循环时角度已经在 360,to 到 360 等于没动——金币转一圈就停了。这个 bug 每次讲 tween 都要提一遍,因为它太像「代码没生效」而不是「逻辑写错」。
链式动作默认顺序执行:
tween(node)
.to(0.2, { scale: new Vec3(1.3, 0.7, 1) }) // 压扁
.delay(0.05) // 停一下
.to(0.15, { scale: new Vec3(1, 1, 1) }) // 弹回
.call(() => { console.log('落地动效结束'); })
.start();
delay 是空等,call 是在链的这一点执行一个普通函数(不涉及任何目标属性)。call 常用来在动效结束时触发后续逻辑,比如「弹窗缩回后销毁自己」。
9.2.4 缓动曲线 easing:手感的来源
线性插值(linear)在动画里几乎从不用——匀速运动看起来又假又廉价。真实世界的东西有惯性:起步慢、中间快、末尾刹住。tween 通过 easing 选项指定曲线:
tween(node)
.to(0.4, { position: new Vec3(0, 0, 0) }, { easing: 'backOut' })
.start();
backOut 的特点是冲过头再回来,特别适合弹窗入场。常用的几条曲线和它们的场合:
| easing | 感觉 | 《方块冲刺》里的用法 |
|---|---|---|
linear | 匀速 | 背景无限滚动 |
quadOut | 快速起步、慢慢停 | 分数数字跳动 |
backOut | 冲过头再回弹 | 弹窗、道具拾取 |
elasticOut | 弹簧式来回 | 通关庆祝 |
bounceOut | 落地弹几下 | 角色落地 |
easing 也可以传自定义函数 (k: number) => number,k 是 0 到 1 的进度。这给了一个自由度:内置曲线不够用时,可以自己写。9.2.6 会写一条。
9.2.5 目标不只是 Node
tween 的泛型目标是「任意对象」,这带来一个很有用的能力:直接补间组件。比如淡出弹窗,改的是 UIOpacity 组件的 opacity,而不是节点本身:
import { _decorator, Component, UIOpacity, tween } from 'cc';
const { ccclass } = _decorator;
@ccclass('PopupFade')
export class PopupFade extends Component {
protected onEnable(): void {
const op = this.getComponent(UIOpacity) ?? this.node.addComponent(UIOpacity);
op.opacity = 0;
tween(op)
.to(0.2, { opacity: 255 }, { easing: 'quadOut' })
.start();
}
}
也可以补间一个纯数据对象,让逻辑层自己管数值:
const counter = { value: 0 };
tween(counter)
.to(0.6, { value: 1280 }, { onUpdate: () => this.label.string = `${Math.round(counter.value)}` })
.start();
onUpdate 在每帧插值后调用,counter.value 就是当前帧的数值。分数从 0 滚到 1280 的动效,靠这几行就够,不需要 Animation 剪辑。
9.2.6 用纯逻辑写一条 easing 曲线
内置曲线覆盖了九成场景,但知道它们怎么来的,出问题时才不慌。下面这条 easeOutBack 与引擎无关,可直接单测:
/** 冲过头再回弹:c1 控制过冲幅度,越大越夸张 */
export function easeOutBack(k: number, c1 = 1.70158): number {
const c3 = c1 + 1;
const p = k - 1;
return 1 + c3 * p * p * p + c1 * p * p;
}
/** 通用补间:从 from 到 to,按 easing 采样 */
export function tweenValue(
from: number,
to: number,
ratio: number,
ease: (k: number) => number = (k) => k,
): number {
return from + (to - from) * ease(ratio);
}
拿它验算:easeOutBack(0.5) 约等于 1.0877,也就是在中点位置已经超过了终点 8.8%——这正是「弹一下」的视觉来源。easeOutBack(1) 精确等于 1,因为 p = 0,两项都归零。写个测试断言 easeOutBack(1) === 1 和 easeOutBack(0.5) > 1,就能锁住这条曲线的两个关键性质。
9.2.7 组合、复用与停止
单个 tween 很快写,但数量一多就需要管理。三个 API 撑起管理:
union():把当前 tween 之前的所有动作打包成一个可复用的整体,之后可以clone()出多份。Tween.stopAllByTarget(target):停掉某个目标上所有 tween。切场景或回收节点时必调,否则 tween 会继续持有已销毁的节点。Tween.stopAllByTag(tag):按tag批量停。适合「暂停游戏时停掉所有动效」这类需求。
import { Node, Tween } from 'cc';
const coinNode = new Node('coin');
// 回收金币节点前,先停掉它身上的旋转 tween
Tween.stopAllByTarget(coinNode);
coinNode.destroy();
一个真实的泄漏场景:金币被磁铁吸走后 destroy() 了节点,但 9.2.2 里那条 repeatForever 的 tween 还在跑。Cocos 的 tween 系统每帧会遍历所有活动 tween,对已销毁的目标调用 setPosition 之类的访问——轻则报错刷屏,重则在原生端崩溃。凡是 repeatForever 的 tween,目标销毁前一定要 stopAllByTarget。
9.2.8 ITweenOption 的四个钩子
to / by 的第三个参数是 ITweenOption,它除了 easing 还提供四个回调钩子,能把动效和业务逻辑接起来:
| 字段 | 触发时机 | 典型用途 |
|---|---|---|
onStart | 动作启动时一次 | 播放音效、禁用输入 |
onUpdate | 每帧插值后 | 同步显示数值、刷新血条 |
onComplete | 动作完成时一次 | 销毁节点、触发下一步 |
progress | 自定义插值公式 | 非线性的特殊曲线 |
一个把「分数滚动」和「结束回调」接起来的例子:
const counter = { value: 0 };
tween(counter)
.to(1.0, { value: score }, {
easing: 'quadOut',
onUpdate: () => { this.scoreLabel.string = `${Math.round(counter.value)}`; },
onComplete: () => { this.onScoreSettled(); },
})
.start();
onComplete 比在链尾接 .call() 更精确:call 是在链的某个位置执行,onComplete 是这个动作本身完成时执行。多个动作并发时(parallel),用 onComplete 才说得清是谁完成了。
9.2.9 什么时候该放弃 tween
tween 不是万能的。以下情况应该改用别的方案:
- 动的是多个属性且要反复播:比如角色受击时整个姿态变化,用 9.1 的 Animation 剪辑。
- 动效依赖物理或碰撞:tween 是纯运动学插值,不参与碰撞,穿过障碍也不会有反应。
- 每帧都要根据输入改目标:比如拖拽跟随,那是
update的活,tween 会和你抢属性。
判断口诀:一次性、单属性、不需要碰撞的,用 tween;其余交给 Animation 或手写 update。
9.2.10 工程视角的收口
这一节做完,工程里多了:
assets/scripts/game/coin-spin.ts:金币旋转,repeatForever+by;assets/scripts/ui/popup-fade.ts:弹窗淡入,目标类型是UIOpacity;assets/scripts/core/easing.ts:easeOutBack等纯函数,有单测覆盖。
到这一步,《方块冲刺》的「动」已经分成了两条清晰的路线:Animation 管角色姿态,tween 管单属性动效。9.3 会补上第三块——粒子与骨骼动画,处理「爆发式特效」和「骨骼驱动的角色」。同时注意,9.2.7 的 Tween.stopAllByTarget 会在第 10 章做顿帧(hitstop)时再次出现,那时它要配合 timeScale 一起用。
阅读导航:上一节:9.1 Animation 编辑器与动画剪辑 · 下一节:9.3 粒子与骨骼动画 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。