引言
「动效」和「动画」是两件事:动画是逐帧画出来的位移,动效是属性随时间从 A 变到 B。Defold 把后者做成了一个内建引擎——go.animate。你不需要写 update 里的一行行 self.t = self.t + dt,只要声明「哪个属性、从哪到哪、多久、用什么节奏」,引擎替你插值。听起来简单,但真正决定手感的是缓动曲线(Easing Curve):同样 0.3 秒的按钮缩放,LINEAR 显得廉价,OUT_BACK 才有「弹一下」的爽感。
本文把补间动画拆成可复用的知识:先讲插值的数学本质,再讲 go.animate 的完整签名与播放模式,接着列出 go.Easing 的全部曲线并给出选型直觉,然后是自定义 easing 函数的实现与曲线拟合,再讲多属性协同与链式序列编排、时长/延迟/暂停/变速的控制手段,最后给 UI 动效与角色运动的实战配方。
1. 补间动画的本质:在时间轴上插值
任何补间都可以写成一行公式:
value(t) = from + (to - from) * ease(t / duration)
↑起点 ↑总变化量 ↑归一化进度经曲线映射
三个变量决定一切:起点 from、终点 to、时间归一化进度 p = t / duration ∈ [0, 1]。ease(p) 就是缓动函数,它把线性进度 p 映射成「非线性进度」。
-- 手写线性补间:理解公式的最好方式
local function lerp_tween(self, from, to, t, duration)
local p = math.min(t / duration, 1) -- 归一化并钳制
return from + (to - from) * p
end
线性 vs 缓动的差别:
| 进度 p | LINEAR | OUT_QUAD | OUT_BOUNCE |
|---|---|---|---|
| 0.25 | 0.25 | 0.44 | 0.06 |
| 0.50 | 0.50 | 0.75 | 0.77 |
| 0.75 | 0.75 | 0.94 | 1.06 |
| 1.00 | 1.00 | 1.00 | 1.00 |
注意 OUT_BOUNCE 在 p=0.75 时值超过 1——这就是「弹过头再回来」的来源。缓动函数允许越界,这正是弹性与回拉效果的数学基础。
心智:补间 = 起点 + 变化量 × 缓动(归一化时间)——所有动效的差异,本质都是这条曲线长得不一样。
2. go.animate:内置补间引擎
完整签名:
go.animate(url, property, playback, to, easing, duration, [delay], [complete_function])
-- 对象 属性 播放模式 目标值 曲线 时长 延迟 完成回调
-- 最小用法:0.3 秒内把节点缩放到 1.2 倍
go.animate("#btn", "scale", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
vmath.vector3(1.2, 1.2, 1), go.Easing.OUT_BACK, 0.3)
-- 带完成回调
go.animate(".", "position", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
vmath.vector3(0, 200, 0), go.Easing.OUT_QUAD, 0.5,
0.1, function(self, complete)
if complete then print("到位") end
end)
播放模式(PlaybackMode):
| 模式 | 行为 | 典型用途 |
|---|---|---|
PLAYBACK_ONCE_FORWARD | 从当前值播到目标 | 位移、缩放 |
PLAYBACK_ONCE_BACKWARD | 从目标反向播回 | 收起 |
PLAYBACK_ONCE_PINGPONG | 正向再反向一次 | 抖动 |
PLAYBACK_LOOP_FORWARD | 循环正向 | 持续脉动 |
PLAYBACK_LOOP_PINGPONG | 来回循环 | 呼吸效果 |
可动画的属性路径:
go.animate 的 property 支持「点路径」访问子分量:
"position" 整体位置
"position.y" 只动 y(上下浮动)
"scale.x" 只动横向缩放
"rotation.z" 2D 旋转(弧度)
"tint.w" 透明度(alpha)
"#sprite" 开头的组件属性同理:#sprite.tint.x
-- 只动透明度:淡入
go.animate("#sprite", "tint.w", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
1.0, go.Easing.OUT_QUAD, 0.25)
心智:go.animate 七参数——对象、属性、模式、目标、曲线、时长、(延迟与回调);属性支持点路径,能只动一个分量。
3. 缓动曲线全表与选型
go.Easing 命名规律:[IN|OUT|IN_OUT]_[曲线名]。
IN_* 开头慢、结尾快(像加速) —— 适合「进入」「下落」
OUT_* 开头快、结尾慢(像减速) —— 适合「到达」「停止」
IN_OUT_* 两头慢、中间快 —— 适合「穿过」「过渡」
曲线名:
LINEAR 匀速
QUAD 二次(温和)
CUBIC 三次(更明显)
QUART 四次
QUINT 五次
SINE 正弦(最柔和,接近自然)
EXPO 指数(极端的快慢对比)
CIRC 圆弧(接近物理减速)
BACK 回拉(越过再退回)
BOUNCE 弹跳(落地反弹)
ELASTIC 弹簧(高频震荡)
选型速查:
| 场景 | 推荐曲线 | 理由 |
|---|---|---|
| UI 面板滑入 | OUT_QUAD / OUT_CUBIC | 快进慢停,干脆 |
| UI 面板收起 | IN_QUAD | 慢起快走,不拖沓 |
| 按钮回弹 | OUT_BACK | 越过目标再回,Q 弹 |
| 掉落着地 | IN_QUAD + 落地 OUT_BOUNCE | 加速下落 + 反弹 |
| 弹性提示(新消息) | OUT_ELASTIC | 高频弹簧,抓注意力 |
| 相机跟随 | SINE / OUT_SINE | 最柔和,不易晕 |
| 进度条填充 | LINEAR | 匀速才符合「进度」语义 |
| 数值滚动(金币计数) | OUT_EXPO | 先快后慢,有「刹车感」 |
-- 三种常见配方的对照
-- 1) 面板滑入:干脆
go.animate("#panel", "position.x", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
0, go.Easing.OUT_CUBIC, 0.28)
-- 2) 按钮回弹:Q 弹
go.animate("#btn", "scale", go.PlaybackMode.PLAYBACK_ONCE_PINGPONG,
vmath.vector3(1.1, 1.1, 1), go.Easing.OUT_BACK, 0.18)
-- 3) 新消息提示:弹簧
go.animate("#toast", "position.y", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
0, go.Easing.OUT_ELASTIC, 0.6)
心智:IN 是加速(进/落)、OUT 是减速(到/停)、IN_OUT 是过渡;BACK 会越界、BOUNCE 会弹、ELASTIC 会震——选曲线先想「这个动作在物理上该怎么停」。
4. 自定义缓动:函数与曲线拟合
go.animate 的 easing 参数也接受一个自定义函数,签名 f(t) -> value,t 为已过时长(秒):
-- 自定义:先冲过头再回到目标的「过冲」曲线
local function overshoot(t, duration)
local p = t / duration
local c = 1.70158 * 1.2
p = p - 1
return p * p * ((c + 1) * p + c) + 1
end
go.animate(".", "position.x", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
400, overshoot, 0.5)
自定义函数的两种语义(Defold 都支持,按参数个数区分):
f(t) → 直接返回插值后的值(最灵活)
f(t, duration) → 返回归一化进度 0~1,引擎再做 from + (to-from)*p
常用自定义配方:
-- 1) 分段曲线:前 70% 用 OUT_QUAD,后 30% 用 BACK 收尾
local function segmented(t, duration)
local p = t / duration
if p < 0.7 then
return 1 - (1 - p / 0.7) ^ 2 * 0.9
else
local q = (p - 0.7) / 0.3
return 0.9 + 0.1 * (q * q * (2.70158 * q - 1.70158) + 1)
end
end
-- 2) 阻尼正弦:衰减震动(掉血抖动)
local function damped_shake(t, duration)
local p = t / duration
return math.sin(p * math.pi * 6) * (1 - p) -- 振幅随时间衰减
end
曲线拟合的实用做法:拿美术给的曲线图(或参考其他引擎的 cubic-bezier(x1,y1,x2,y2)),用牛顿迭代反解贝塞尔:
-- 三次贝塞尔 y 值(x 已按时间归一)
local function cubic_bezier_y(t, x1, y1, x2, y2)
local function bez(a, b, u) return 3*a*u*(1-u)^2 + 3*b*u^2*(1-u) + u^3 end
return bez(y1, y2, t)
end
拟合流程:
1. 取参考曲线控制点(如 CSS ease-in-out = cubic-bezier(0.42,0,0.58,1))
2. 对每个采样 t 求 y
3. 包成 f(t, duration) 交给 go.animate
心智:内置曲线不够用时,传一个函数进去——
f(t, duration)返回归一化进度最通用,贝塞尔反解让你能复刻任何参考曲线。
5. 多属性协同与链式编排
多个属性同时动:分别调用 go.animate,它们并行推进,互不阻塞:
-- 同时位移 + 缩放 + 淡出(三路并行)
local url = msg.url("#card")
go.animate(url, "position.y", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
-300, go.Easing.OUT_CUBIC, 0.4)
go.animate(url, "scale", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
vmath.vector3(0.8, 0.8, 1), go.Easing.OUT_QUAD, 0.4)
go.animate(url, "tint.w", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
0, go.Easing.IN_QUAD, 0.4)
串行(链式)编排:用完成回调把动画串成序列:
-- 链式:放大 → 停顿 → 缩小
local function shrink(self)
go.animate(".", "scale", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
vmath.vector3(1, 1, 1), go.Easing.IN_OUT_BACK, 0.2)
end
local function grow(self)
go.animate(".", "scale", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
vmath.vector3(1.4, 1.4, 1), go.Easing.OUT_BACK, 0.2,
0, function(self, complete)
if complete then shrink(self) end
end)
end
用延迟参数做「错峰」(Stagger)——列表项逐个入场是常见需求:
-- 每个卡片延迟 0.05s 依次滑入
for i, card_url in ipairs(self.card_urls) do
go.animate(card_url, "position.x", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
0, go.Easing.OUT_CUBIC, 0.35, i * 0.05) -- delay = i * 0.05
end
编排三原则:
1. 并行:分别 go.animate,天然同时进行
2. 串行:用 complete_function 里调下一个
3. 错峰:用 delay 参数,别写定时器轮询
心智:并行靠多次调用、串行靠完成回调、错峰靠 delay 参数——编排不需要额外的调度器,go.animate 自带这三件套。
6. 时间控制:暂停、变速与取消
取消正在进行的动画:
go.cancel_animations(".", "position") -- 取消某属性
go.cancel_animations(".", go.PROP_ANIM_POSITION) -- 等价的枚举写法
go.cancel_animations(".") -- 取消该对象全部动画
注意:cancel 后属性停在「当前值」,不会回到起点
——这正是「打断动画」想要的效果(从当前位置继续)
暂停 / 恢复的工程做法:go.animate 没有内建暂停。要让整组动画随游戏暂停,用「时间缩放」思路:
-- 游戏暂停时,把动画时长按比例「冻结」是不可行的(go.animate 无法中途改速)
-- 正解:暂停时 cancel 并记录进度,恢复时从记录值续播
function pause_tween(self, url, prop)
self.saved[prop] = go.get(url, prop) -- 记住当前值
go.cancel_animations(url, prop)
end
function resume_tween(self, url, prop, target, easing, dur)
go.animate(url, prop, go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
target, easing, dur) -- 从当前值续播到原目标
end
变速(慢动作):go.animate 的时长是固定的,慢动作应作用在游戏时间上而非单条动画——把 dt 乘一个系数,只在 update 驱动的动画上生效:
function update(self, dt)
local scaled = dt * self.time_scale -- 0.3 = 慢动作
self.t = self.t + scaled
go.set(".", "position", self:curve_at(self.t))
end
用 go.get 读动画中的实时值:
-- 动画进行中读取当前缩放(用于同步其他表现)
local cur = go.get("#card", "scale")
local alpha = go.get("#card", "tint.w")
心智:cancel 停在当前值(适合打断)、没有内建暂停(靠记录值续播)、慢动作要作用在 dt 上而非单条动画——控制粒度决定了动效能不能「被打断」。
7. 实战:UI 动效与角色运动
UI 动效配方(弹窗出现):
local function show_dialog(self)
local url = msg.url("#dialog")
go.set(url, "scale", vmath.vector3(0.7, 0.7, 1)) -- 起始小
go.set(url, "tint.w", 0) -- 起始透明
go.animate(url, "scale", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
vmath.vector3(1, 1, 1), go.Easing.OUT_BACK, 0.28)
go.animate(url, "tint.w", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
1, go.Easing.OUT_QUAD, 0.18)
end
数值滚动(金币计数)——用 go.animate 驱动一个「虚拟属性」再在回调里刷新文本:
-- 把 0~1 的进度映射到 0~目标值
go.animate("#counter", "position.x", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
1, go.Easing.OUT_EXPO, 0.8,
0, function(self, complete)
local p = go.get("#counter", "position.x") -- 借用一个不渲染的属性
local shown = math.floor(self.target_gold * p)
label.set_text("#gold_label", tostring(shown))
end)
更干净的做法是用一个不可见对象承载「进度属性」,避免借用 position 造成视觉抖动。
角色运动配方(跳跃弧线):
-- 跳跃 = 水平匀速 + 垂直缓动(两个属性用不同曲线)
go.animate(".", "position.x", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
self.start_x + 160, go.Easing.LINEAR, 0.6)
go.animate(".", "position.y", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
self.start_y + 90, go.Easing.OUT_QUAD, 0.3,
0, function(self, complete)
if complete then
-- 上升结束 → 下落
go.animate(".", "position.y", go.PlaybackMode.PLAYBACK_ONCE_FORWARD,
self.start_y, go.Easing.IN_QUAD, 0.3)
end
end)
受击抖动(Shake):
-- 用 PINGPONG + 短时长做「来回抖」
for i = 1, 3 do
go.animate(".", "position.x", go.PlaybackMode.PLAYBACK_ONCE_PINGPONG,
self.base_x + 8, go.Easing.LINEAR, 0.05, (i - 1) * 0.1)
end
动效性能纪律:
- 只动「位置/缩放/旋转/透明度」——这几类不触发重建
- 不要用 go.animate 动「尺寸(size)」这类需要重算图集的属性
- 同屏同时进行的动画数量控制在几十条以内,超了就改程序动画
- 动效结束即 cancel 清理,避免残留动画继续写属性
心智:UI 动效靠「起始值 go.set + 目标值 go.animate」;角色运动靠「不同属性配不同曲线 + 完成回调接力」——把缓动曲线当参数调,手感是调出来的不是写出来的。
速查表
| 需求 | 做法 |
|---|---|
| 属性补间 | go.animate(url, prop, mode, to, easing, dur) |
| 只动一个分量 | 属性路径 "position.y" / "tint.w" |
| 加速(进/落) | IN_QUAD / IN_CUBIC |
| 减速(到/停) | OUT_QUAD / OUT_CUBIC |
| 回弹 / 弹簧 | OUT_BACK / OUT_ELASTIC |
| 弹跳着地 | OUT_BOUNCE |
| 柔和跟随 | SINE |
| 自定义曲线 | 传 function(t, duration) |
| 并行多属性 | 多次调用 go.animate |
| 串行序列 | 完成回调里调下一个 |
| 错峰入场 | 第 7 个参数 delay |
| 取消动画 | go.cancel_animations(url, prop) |
| 慢动作 | 缩放 dt,作用在程序动画上 |
一句话记忆:补间 = from + (to - from) × ease(t/duration)——go.animate 七参数管声明,IN 管加速、OUT 管减速、BACK/BOUNCE/ELASTIC 管越界手感;内置不够就传自定义函数,并行多次调用、串行走完成回调、错峰用 delay;cancel 停在当前值所以能被打断,慢动作只作用在 dt 上——手感是曲线调出来的。
小结
补间动画的价值在于把「怎么动」从代码里抽出来变成数据:属性、目标、时长、曲线四个参数一填,动效就成型了。工程上抓住四点即可:一是选曲线先想物理——加速用 IN、减速用 OUT、要「Q 弹」才上 BACK/ELASTIC;二是编排只用三种原语(并行多次调用、串行完成回调、错峰 delay 参数),别自己造调度器;三是自定义曲线永远走 f(t, duration) 归一化形态,这样换 from/to 不用改函数;四是性能上只动位置/缩放/旋转/透明度,尺寸类属性交给图集重建是不划算的。把这四点做扎实,Defold 的动效就能覆盖从 UI 到角色运动的绝大多数需求。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。