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


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


二、缓动函数对照表
| GDScript 写法 | Lua 实现 | 用途 |
| EASE_OUT + TRANS_BACK | EASE.BackOut(t, s) s=1.70158 | 悬停缩放、释放弹跳 |
| EASE_OUT + TRANS_CUBIC | EASE.CubicOut(t) | 旋转回正、平滑过渡 |
| EASE_OUT + TRANS_ELASTIC | EASE.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


三、补间引擎设计
数据结构
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 参数错开。


四、六大动画技巧移植对照
技巧 1:悬停动画(缩放 + 旋转)
GDScript 核心:分离 X/Y 缩放时间 + 随机旋转方向 + EASE_OUT TRANS_BACK
Lua 实现:
lua复制
lualocal 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:根据按钮大小调整动画强度
GDScript:clampf(width_full_rot / size.x, 0.5, 1.0)
Lua 实现:
lua复制
lualocal 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复制
luafor 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复制
lualocal 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复制
lualocal 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),模拟"被按扁"的物理感。


五、NanoVG 渲染变换(对标 GDScript pivot_offset)
GDScript 中 pivot_offset = size / 2.0 让缩放/旋转以中心为原点。NanoVG 没有 pivot 概念,必须手动变换:
lua复制
lualocal 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 之间设置,控制入场透明度


六、状态机与动画优先级
入场动画(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)


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


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


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


十、实测落地成功方案(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 三条铁律
- 静态可见铁律:按钮构建时 scaleX=1, scaleY=1, alpha=1,绝不用动画"让它出现"。动画只做 hover/press 增强,不是可见性前提。
- 帧内驱动铁律:动画在 _Render 里用 os.clock() 算 dt + 逐帧 lerp 推进。鼠标事件只改目标值,不直接改显示值。
- 浮点色铁律:一律 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复制
lualocal 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复制
luaself._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.2、rotImpulse=15*ratio*dir。
_Render 内逐帧推进(帧内驱动铁律)
lua复制
luafunction 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复制
luafunction 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 事件) | _Render 内 os.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 帧内方案。

