《Cocos 游戏开发实战》9.2 Tween 与程序动画

金币旋转、道具闪烁、弹窗弹入——这些「一个数在变」的动效不值得开动画编辑器。本节讲透 Cocos 3.8 的 tween:目标、动作、控制三层模型,to/by/sequence/parallel 的组合,easing 曲线怎么挑手感,以及目标被销毁导致的 tween 泄漏怎么防。

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 粒子与骨骼动画 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画