UI 按键动画设计经验

07/292 浏览综合

从 GDScript (Godot Tween) 移植到 UrhoX Lua + raw NanoVG 的完整经验总结。
版本: v0.3.0 | 日期: 2026-07-28
重要:第 1~9 节是"设计理论 + 自建补间引擎"思路(适合理解原理)。
但在《抓周》这类 Boot/Router/EventBus 模板里实测落地时,靠 Update 驱动补间会让按钮不可见、nvgRGBA 整数色会反转。
最终成功方案见第十节:_Render 帧内 lerp + nvgRGBAf + 静态可见铁律。 落地时优先照第十节抄。
horizontal linehorizontal line

一、设计思路总览

核心原则:Godot 有内置 Tween 系统,UrhoX 没有。我们用纯 Lua 实现一个逐帧驱动的轻量补间引擎,然后在 NanoVG 绘制时读取补间状态做变换。
架构分层
缓动函数层(EASE)     ← 纯数学,对标 Godot Tween 曲线
    ↓
补间引擎层(Tween)    ← 属性插值,支持 delay / duration / 独立缓动
    ↓
按钮状态层(MenuButton)← 持有 scaleX / scaleY / rotation / alpha
    ↓
渲染层(DrawButton)   ← nvgSave → Translate(中心) → Rotate → Scale → 绘制 → nvgRestore
horizontal linehorizontal line

二、缓动函数对照表

GDScript 写法Lua 实现用途
EASE_OUT + TRANS_BACKEASE.BackOut(t, s) s=1.70158悬停缩放、释放弹跳
EASE_OUT + TRANS_CUBICEASE.CubicOut(t)旋转回正、平滑过渡
EASE_OUT + TRANS_ELASTICEASE.ElasticOut(t)预留:弹性衰减
EASE_IN_OUT + TRANS_CUBIC可用 1 - CubicOut(1-t) 组合显示/隐藏容器

关键公式

lua复制
lua
-- BackOut:过冲回弹(最常用) function EASE.BackOut(t, s) s = s or 1.70158 t = t - 1.0 return t * t * ((s + 1.0) * t + s) + 1.0 end -- ElasticOut:弹性衰减 -- 注意:Lua 5.4 无 math.pow,必须用 ^ 运算符 return (2.0 ^ (-10.0 * t)) * math.sin((t - p * 0.25) * (2.0 * math.pi) / p) + 1.0
horizontal linehorizontal line

三、补间引擎设计

数据结构

lua复制
lua
---@class TweenProp -- 单属性补间 ---@field from number -- 起始值 ---@field to number -- 目标值 ---@field duration number -- 持续时间(秒) ---@field elapsed number -- 已经过时间 ---@field delay number -- 延迟启动(秒) ---@field easing fun(t: number): number -- 缓动函数 ---@class TweenAnim -- 一组属性补间 ---@field props table<string, TweenProp> ---@field done boolean

驱动方式

每帧在 HandleUpdate 中调用 TweenStep(anim, dt),返回 { key = currentValue } 表,直接赋给按钮状态字段。
lua复制
lua
-- 对标 GDScript: tween.tween_property(self, "scale:x", 1.2, 0.2) TweenProp(anim, "scaleX", from, to, 0.2, 0, EASE.BackOut) -- 对标 GDScript: .set_delay(0.15) TweenProp(anim, "scaleY", from, to, 0.35, 0.15, EASE.BackOut)

与 GDScript parallel 的对应

GDScript 的 tween.parallel() 在同一个 TweenAnim 里天然并行——所有 props 共享 elapsed 计时。需要串行时,用 delay 参数错开。
horizontal linehorizontal line

四、六大动画技巧移植对照

技巧 1:悬停动画(缩放 + 旋转)

GDScript 核心:分离 X/Y 缩放时间 + 随机旋转方向 + EASE_OUT TRANS_BACK
Lua 实现
lua复制
lua
local function TriggerHoverAnim(btn) local anim = TweenNew() -- 分离 X/Y 轴:X 快(0.2s),Y 慢(0.35s)→ 产生"挤压拉伸"感 TweenProp(anim, "scaleX", btn.scaleX, scaleTarget, 0.20, 0, EASE.BackOut) TweenProp(anim, "scaleY", btn.scaleY, scaleTarget, 0.35, 0, EASE.BackOut) -- 随机旋转方向:先偏转再回正(两段式) TweenProp(anim, "rotation", 0, rotTarget * dir, 0.10, 0, EASE.CubicOut) TweenProp(anim, "rotation", rotTarget * dir, 0, 0.15, 0.10, EASE.CubicOut) btn.hoverAnim = anim end
要点
  • X/Y 不同 duration 产生弹性拉伸感,比等比缩放更有活力
  • 旋转分两段(偏转 → 回正),delay 衔接,模拟"晃动后稳定"

技巧 2:根据按钮大小调整动画强度

GDScriptclampf(width_full_rot / size.x, 0.5, 1.0)
Lua 实现
lua复制
lua
local widthFullRot = 400.0 -- 全强度参考宽度 local sizeRatio = math.max(0.5, math.min(1.0, widthFullRot / btn.width)) local scaleTarget = 1.0 + 0.15 * sizeRatio -- 大按钮缩放幅度小 local rotTarget = 6.0 * sizeRatio -- 大按钮旋转角度小
原理:540px 宽的按钮如果旋转 6° 会显得夸张,用比例衰减到合理范围。

技巧 3:容器内子元素错序入场

GDScript:遍历子节点,modulate.a = 0,parallel tween + set_delay(delay * idx)
Lua 实现
lua复制
lua
for i, btn in ipairs(buttons_) do local anim = TweenNew() local delay = 0.3 + (i - 1) * 0.15 -- 每个按钮错开 150ms TweenProp(anim, "scaleX", 0, 1.0, 0.5, delay, EASE.BackOut) TweenProp(anim, "scaleY", 0, 1.0, 0.6, delay, EASE.BackOut) TweenProp(anim, "alpha", 0, 1.0, 0.01, delay, EASE.CubicOut) -- 瞬间可见 TweenProp(anim, "rotation", -8, 0, 0.4, delay, EASE.CubicOut) -- 旋转回正 btn.entranceAnim = anim end
要点
  • alpha 用 0.01s 极短时间 = “突然出现”,不是渐变(对标 GDScript 的 modulate:a 0.01s
  • 入场旋转从 -8° 回正,增加动感
  • 错序 delay 让按钮像"依次弹出"而非同时出现

技巧 4/6:属性变化自动触发弹跳(释放/点击反馈)

GDScript_set() 拦截属性变化 → animate() 弹跳
Lua 实现(释放弹跳):
lua复制
lua
local function TriggerReleaseAnim(btn) local anim = TweenNew() local rotDir = (math.random() > 0.5) and 1.0 or -1.0 -- 先弹大(BackOut 过冲) TweenProp(anim, "scaleX", btn.scaleX, 1.12, 0.12, 0, EASE.BackOut) TweenProp(anim, "scaleY", btn.scaleY, 1.12, 0.15, 0, EASE.BackOut) -- 再回落到 1.0 TweenProp(anim, "scaleX", 1.12, 1.0, 0.20, 0.12, EASE.CubicOut) TweenProp(anim, "scaleY", 1.12, 1.0, 0.25, 0.15, EASE.CubicOut) -- 随机旋转晃动 TweenProp(anim, "rotation", 0, 4.0 * rotDir, 0.08, 0, EASE.CubicOut) TweenProp(anim, "rotation", 4.0 * rotDir, 0, 0.12, 0.08, EASE.CubicOut) btn.pressAnim = anim end
要点
  • 两段式缩放(弹大 → 回落)= GDScript 的 scale:x 1.2 → 1.0 模式
  • 同一 key 出现两次 TweenProp,后者 delay 衔接前者结束时间
  • 随机旋转方向避免每次看起来一样

技巧 5:按下反馈(快速缩小)

lua复制
lua
local function TriggerPressAnim(btn) local anim = TweenNew() TweenProp(anim, "scaleX", btn.scaleX, 0.92, 0.08, 0, EASE.CubicOut) TweenProp(anim, "scaleY", btn.scaleY, 0.88, 0.10, 0, EASE.CubicOut) btn.pressAnim = anim btn.hoverAnim = nil -- 取消悬停动画 end
要点:Y 轴比 X 轴缩得更多(0.88 vs 0.92),模拟"被按扁"的物理感。
horizontal linehorizontal line

五、NanoVG 渲染变换(对标 GDScript pivot_offset)

GDScript 中 pivot_offset = size / 2.0 让缩放/旋转以中心为原点。NanoVG 没有 pivot 概念,必须手动变换:
lua复制
lua
local function DrawButton(ctx, fontId, btn) local cx = btn.x + btn.width * 0.5 local cy = btn.y + btn.height * 0.5 nvgSave(ctx) nvgTranslate(ctx, cx, cy) -- 移到中心 nvgRotate(ctx, btn.rotation * math.pi / 180) -- 旋转(弧度) nvgScale(ctx, btn.scaleX, btn.scaleY) -- 缩放 -- 以 (0,0) 为中心绘制按钮(坐标用 -halfW, -halfH) nvgRoundedRect(ctx, -halfW, -halfH, btn.width, btn.height, 28) -- ... 填充、描边、文字 ... nvgRestore(ctx) end
踩坑记录
  • 忘记 nvgSave/nvgRestore → 变换累积,后续绘制全部错位
  • 旋转角度必须转弧度:rotation * math.pi / 180
  • nvgGlobalAlpha 在 Save/Restore 之间设置,控制入场透明度
horizontal linehorizontal line

六、状态机与动画优先级

入场动画(entranceAnim)
    ↓ 完成后
悬停动画(hoverAnim)  ←→  按下动画(pressAnim)
    ↑ 鼠标离开时              ↑ 鼠标按下时
    TriggerReleaseAnim        TriggerPressAnim
驱动逻辑(每帧):
lua复制
lua
-- 入场优先 if btn.entranceAnim and not btn.entranceAnim.done then -- 驱动入场 end -- 按下优先于悬停 local activeAnim = btn.pressAnim or btn.hoverAnim if activeAnim and not activeAnim.done then -- 驱动交互动画 end
状态切换规则
  • 鼠标进入 → 取消旧 hover,触发新 hover
  • 鼠标按下 → 取消 hover,触发 press
  • 鼠标释放 → 取消 press,触发 release(弹跳)
  • 鼠标离开 → 触发 release(回弹到 1.0)
horizontal linehorizontal line

七、Lua 移植踩坑清单

问题原因解决
math.pow 不存在Lua 5.4 移除了 math.pow^ 运算符:2.0 ^ (-10.0 * t)
LSP 报 unnecessary-ifTweenStep 返回的 table 字段类型是 number(非 nil)v.scaleX or btn.scaleX 替代 if 判断
LSP 报 param-type-mismatchbuttons_[idx] 可能是 nil先取到 local 变量做 nil 检查再传参
NanoVG 缩放以左上角为原点NanoVG 无 pivot 概念nvgTranslate 到中心再缩放(见第五节)
旋转方向每次都一样没用随机math.random() > 0.5 决定方向
补间动画"跳帧"delay 期间 elapsed 在涨但 t 被 clamp 到 0这是正确的,delay 期间值保持在 from
horizontal linehorizontal line

八、参数速查

参数说明
悬停缩放目标1.0 + 0.15 * sizeRatiosizeRatio ∈ [0.5, 1.0]
悬停旋转角度6.0 * sizeRatio先偏转再回正
悬停 X 轴时长0.20sBackOut
悬停 Y 轴时长0.35sBackOut(比 X 慢 → 拉伸感)
按下缩放X: 0.92, Y: 0.88Y 更小 → 按扁感
释放弹跳峰值1.12BackOut 过冲
释放回落时长X: 0.20s, Y: 0.25sCubicOut
入场错序间隔150ms / 按钮0.3 + (i-1) * 0.15
入场旋转-8° → 0°CubicOut 回正
BackOut 过冲量 s1.70158标准值,越大回弹越明显
widthFullRot 参考宽度400px小于此宽度全强度,大于则衰减
horizontal linehorizontal line

九、扩展方向

  • 数值变化弹跳:分数/血量变化时触发 scale 弹跳(对标 GDScript 技巧 6 的 _set 拦截)
  • 容器显示/隐藏:面板整体 scale 0→1 或 fade 0→1(对标技巧 5)
  • ElasticOut:用于特殊强调效果(如获得稀有物品)
  • 音频联动:在 TriggerPressAnim / TriggerReleaseAnim 中播放音效
horizontal linehorizontal line

十、实测落地成功方案(v0.3.0,照抄这节)

前 1~9 节的"自建补间引擎 + Update 驱动"在《抓周》的 Boot/Router/EventBus 模板里实测反复失败
本节是经过多轮调试后真正跑通的方案,落地时直接照抄,不要再走自建补间 + Update 的老路。

10.1 为什么前九节方案落地会失败(三个坑)

现象根因失败链路
截图里按钮完全不可见(空白面板/小白点)入场动画把 scale/alpha 初始设 0,靠 Update 驱动 Tween 补到 1;但页面级 Update 在该模板里时序不可靠,补间不推进 → 永远停在 0可见性 = 动画的产物 = 脆弱
背景/按钮颜色反转(米色背景变棕、棕色按钮变米色)用了 nvgRGBA(r,g,b,255) 整数版,在该构建里出现通道映射异常整数色不可信
按钮矩形在,文字消失文字色与填充色太接近;或用了 DrawStrokedText 描边覆盖填充对比度 + 描边陷阱
核心教训NanoVGRender_Render)是唯一每帧 100% 可靠的回调;Update 不可信。一切依赖"每帧推进"的逻辑(动画、计时)必须放进 _Render

10.2 三条铁律

  1. 静态可见铁律:按钮构建时 scaleX=1, scaleY=1, alpha=1绝不用动画"让它出现"。动画只做 hover/press 增强,不是可见性前提。
  2. 帧内驱动铁律:动画在 _Render 里用 os.clock() 算 dt + 逐帧 lerp 推进。鼠标事件只改目标值,不直接改显示值。
  3. 浮点色铁律:一律 nvgRGBAf(0..1);文字用纯白 nvgRGBAf(1,1,1,1) 配深色按钮,不用描边文字

10.3 架构:双层状态 + 帧内 lerp

鼠标事件层  ──只写目标──▶  AnimState { 当前值, 目标值, 旋转相位 }
                                  │
_Render 每帧 ──os.clock() dt──▶ _TickAnim: lerp(当前 → 目标)
                                  │
                          _DrawButton: 读当前值做 nvgTranslate/Rotate/Scale
  • 分离 X/Y lerp 速度 = GDScript 的 scale:x 0.2s / scale:y 0.35s(Y 慢 → 挤压拉伸感)
  • 旋转两阶段(flick 甩出 0.1s → return 弹回)= GDScript 的 rotation → target→ 0 set_delay
  • 随机方向 math.random() > 0.5 and 1 or -1 = GDScript 的 pick_random()
  • 尺寸自适应强度 clamp(WIDTH_FULL_ROT / w, 0.5, 1.0) 原样移植

10.4 关键代码(最终成功版,精简)

动画状态结构

lua复制
lua
---@class AnimState ---@field scaleX number -- 当前显示值 ---@field scaleY number ---@field rotation number ---@field targetScaleX number -- 鼠标事件写入的目标 ---@field targetScaleY number ---@field rotPhase number -- 0=idle, 1=甩出, 2=弹回 ---@field rotTimer number ---@field rotImpulse number -- 本次甩出的目标角度(含随机方向) -- 构建时默认全 1(静态可见铁律) self._anim[i] = { scaleX = 1.0, scaleY = 1.0, rotation = 0, targetScaleX = 1.0, targetScaleY = 1.0, rotPhase = 0, rotTimer = 0, rotImpulse = 0, }

缓动常量

lua复制
lua
local WIDTH_FULL_ROT = 300 -- 全强度参考宽(GDScript width_full_rot) local LERP_SCALE_X = 0.18 -- X 快 ≈ 0.2s local LERP_SCALE_Y = 0.10 -- Y 慢 ≈ 0.35s(拉伸感) local LERP_ROT = 0.25 local FLICK_DURATION = 0.1 -- 甩出阶段时长 local function lerp(a, b, t) return a + (b - a) * t end

鼠标事件:只改目标(悬停 = 技巧1+2)

lua复制
lua
self._bus:On("MouseMove", function(_, ed) if not self._visible then return end local x, y = display:ScreenToDesign(ed:GetInt("X"), ed:GetInt("Y")) local idx = self:_HitTest(x, y) if idx == self.hoveredIdx then return end -- 离开旧按钮:目标回 1,旋转归零 if self.hoveredIdx then local a = self._anim[self.hoveredIdx] a.targetScaleX, a.targetScaleY, a.rotPhase = 1.0, 1.0, 0 end -- 进入新按钮:算尺寸自适应强度 + 随机旋转甩出 if idx then local btn = self.buttons[idx]; local a = self._anim[idx] local ratio = math.max(0.5, math.min(1.0, WIDTH_FULL_ROT / btn.w)) local st = 1.0 + 0.2 * ratio local dir = (math.random() > 0.5) and 1 or -1 a.targetScaleX, a.targetScaleY = st, st a.rotPhase, a.rotTimer, a.rotImpulse = 1, 0, 5.0 * ratio * dir end self.hoveredIdx = idx end)
按下/释放同理只写目标;释放命中写弹跳目标(技巧6):targetScale=1.2rotImpulse=15*ratio*dir

_Render 内逐帧推进(帧内驱动铁律)

lua复制
lua
function Screen:_Render() local vg = self.vg if vg == nil or self.fontId == -1 or not self._visible then return end -- 可靠 dt(os.clock,不依赖 Update) local now = os.clock() local dt = now - self._lastClock if dt > 0.1 then dt = 0.016 end -- 防跳帧 self._lastClock = now self:_TickAnim(dt) -- 目标回拉:弹跳(scale>1.15)结束后,把目标设回"悬停值"或"默认1.0" for i, a in ipairs(self._anim) do if self.pressedIdx ~= i and a.scaleX < 1.15 then if self.hoveredIdx == i then local ratio = math.max(0.5, math.min(1.0, WIDTH_FULL_ROT / self.buttons[i].w)) local st = 1.0 + 0.2 * ratio a.targetScaleX, a.targetScaleY = st, st else a.targetScaleX, a.targetScaleY = 1.0, 1.0 end end end local lw, lh, dpr = display:BeginFrameArgs() nvgBeginFrame(vg, lw, lh, dpr); display:ApplyDesignTransform(vg) -- ... 画背景/标题 ... for i, btn in ipairs(self.buttons) do self:_DrawButton(vg, self.fontId, btn, i) end nvgEndFrame(vg) end function Screen:_TickAnim(dt) for _, a in ipairs(self._anim) do a.scaleX = lerp(a.scaleX, a.targetScaleX, LERP_SCALE_X) -- 分离 X a.scaleY = lerp(a.scaleY, a.targetScaleY, LERP_SCALE_Y) -- 分离 Y(慢) if a.rotPhase == 1 then -- 甩出 a.rotation = lerp(a.rotation, a.rotImpulse, LERP_ROT) a.rotTimer = a.rotTimer + dt if a.rotTimer >= FLICK_DURATION then a.rotPhase = 2 end elseif a.rotPhase == 2 then -- 弹回 a.rotation = lerp(a.rotation, 0, LERP_ROT) a.rotTimer = a.rotTimer + dt if a.rotTimer >= FLICK_DURATION or math.abs(a.rotation) < 0.1 then a.rotation, a.rotPhase = 0, 0 end elseif math.abs(a.rotation) > 0.01 then -- idle 缓归零 a.rotation = lerp(a.rotation, 0, LERP_ROT * 0.5) else a.rotation = 0 end end end

绘制:nvgRGBAf + 中心变换 + 纯白文字

lua复制
lua
function Screen:_DrawButton(vg, fontId, btn, idx) local a = self._anim[idx] local halfW, halfH = btn.w * 0.5, btn.h * 0.5 nvgSave(vg) nvgTranslate(vg, btn.cx, btn.cy) -- 中心为 pivot nvgRotate(vg, a.rotation * math.pi / 180) -- 度→弧度 nvgScale(vg, a.scaleX, a.scaleY) local r, g, b = 0.44, 0.29, 0.23 -- 浮点色铁律 if self.hoveredIdx == idx then r, g, b = 0.55, 0.36, 0.26 end if self.pressedIdx == idx then r, g, b = 0.35, 0.18, 0.18 end nvgBeginPath(vg); nvgRoundedRect(vg, -halfW, -halfH, btn.w, btn.h, 28) nvgFillColor(vg, nvgRGBAf(r, g, b, 1.0)); nvgFill(vg) nvgBeginPath(vg); nvgRoundedRect(vg, -halfW, -halfH, btn.w, btn.h, 28) nvgStrokeColor(vg, nvgRGBAf(0.27, 0.18, 0.10, 1.0)); nvgStrokeWidth(vg, 8); nvgStroke(vg) nvgFontFaceId(vg, fontId); nvgFontSize(vg, 58) nvgTextAlign(vg, NVG_ALIGN_CENTER + NVG_ALIGN_MIDDLE) nvgFillColor(vg, nvgRGBAf(1.0, 1.0, 1.0, 1.0)) -- 纯白文字,不用描边 nvgText(vg, 0, 0, btn.label, nil) nvgRestore(vg) end

10.5 v0.3.0 踩坑对照表(补充第七节)

现象错误做法正确做法
按钮不可见入场 alpha/scale=0 靠 Update+Tween 补初始 =1 静态可见;要入场感用 _Render 时间线,且失败也不影响可见
动画不动依赖 Tween 服务(吃 Update 事件)_Renderos.clock() dt + lerp
颜色反转nvgRGBA(int,int,int,255)nvgRGBAf(0..1)
文字消失文字色≈填充色 / DrawStrokedText 描边盖填充纯白文字 + 深色按钮,直接 nvgText
dt 跳变首帧 os.clock() 差值巨大if dt > 0.1 then dt = 0.016 end 钳制
弹跳后停在大尺寸释放写 1.2 后没拉回目标_ResolveTargets:scale 回落到 <1.15 后把目标设回悬停/1.0

10.6 何时仍可用自建补间引擎(前九节)

  • 单文件 Demo / 示例(自己掌控 Start+Update 主循环,Update 一定跑)→ 自建 Tween 没问题。
  • Boot/Router/EventBus 多页面模板、页面 Update 不一定每帧转发 → 必须用本节的 _Render 帧内方案。