按键 UI 动画
这套项目是 Lua + NanoVG,不直接使用 GDScript 的 Tween,但可以用:
```lua
lerp + 状态机 + nvgSave/nvgTranslate/nvgRotate/nvgScale
```
实现同样的效果。
---
# 1. 动画状态要独立保存
不要只用一个 hovered 布尔值直接画按钮。每个可动画按钮至少要有:
```lua
{
scaleX = 1.0,
scaleY = 1.0,
rotation = 0,
targetScaleX = 1.0,
targetScaleY = 1.0,
rotPhase = 0,
rotTimer = 0,
rotImpulse = 0,
}
```
关键字段:
- scaleX/scaleY:当前动画值
- targetScaleX/targetScaleY:目标值
- rotation:当前旋转角度
- rotPhase:旋转阶段
- 0:静止
- 1:甩出
- 2:回弹
- rotImpulse:随机旋转目标
项目中的实现位置:
- UI/MainMenuScreen.lua:27-35
- Pages/ZhuaZhou.lua:33-41
- Pages/Settings.lua:48-61
- Pages/GlobalShell.lua:20-35
- Pages/Desktop/HUD.lua:20-35
---
# 2. 悬停动画:缩放 + 随机旋转
核心思路:
```lua
local ratio = math.max(0.5, math.min(1.0, WIDTH_FULL_ROT / buttonWidth))
local scaleTarget = 1.0 + 0.2 * ratio
anim.targetScaleX = scaleTarget
anim.targetScaleY = scaleTarget
anim.rotPhase = 1
anim.rotTimer = 0
anim.rotImpulse = 5.0 ratio randomDirection
```
项目中的完整关键代码:
```lua
local ratio = math.max(0.5, math.min(1.0, WIDTH_FULL_ROT / btn.w))
local scaleTarget = 1.0 + 0.2 * ratio
local rotDir = (math.random() > 0.5) and 1 or -1
anim.targetScaleX = scaleTarget
anim.targetScaleY = scaleTarget
anim.rotPhase = 1
anim.rotTimer = 0
anim.rotImpulse = 5.0 ratio rotDir
```
来源:
- UI/MainMenuScreen.lua:115-124
- Pages/Settings.lua:289-298
- Pages/GlobalShell.lua:68-78
- Pages/Desktop/HUD.lua:35-45
## 为什么要按按钮宽度限制
长按钮如果固定旋转 5°,视觉上容易显得夸张。使用:
```lua
clamp(WIDTH_FULL_ROT / width, 0.5, 1.0)
```
可以让:
- 短按钮动画更明显
- 长按钮动画更克制
- 不同尺寸按钮保持相近的视觉比例
---
# 3. X/Y 缩放速度分离
不要让两个轴完全同步:
```lua
anim.scaleX = lerp(anim.scaleX, anim.targetScaleX, 0.18)
anim.scaleY = lerp(anim.scaleY, anim.targetScaleY, 0.10)
```
效果:
- X 轴反应快
- Y 轴反应慢
- 按钮会有轻微“果冻感”
- 比单纯 scale = target 更有生命力
项目中的实现:
```lua
a.scaleX = lerp(a.scaleX, a.targetScaleX, LERP_SCALE_X)
a.scaleY = lerp(a.scaleY, a.targetScaleY, LERP_SCALE_Y)
```
来源:
- UI/MainMenuScreen.lua:291-293
- Pages/ZhuaZhou.lua:275-276
- Pages/Settings.lua:352-353
- Pages/GlobalShell.lua:116-117
- Pages/Desktop/HUD.lua:110-111
---
# 4. 旋转要使用两阶段状态机
不要直接把旋转角度设成 0。应该分为:
```text
甩出 → 回到 0 → 静止
```
关键代码:
```lua
if anim.rotPhase == 1 then
anim.rotation = lerp(anim.rotation, anim.rotImpulse, 0.25)
anim.rotTimer = anim.rotTimer + dt
if anim.rotTimer >= 0.1 then
anim.rotPhase = 2
anim.rotTimer = 0
end
elseif anim.rotPhase == 2 then
anim.rotation = lerp(anim.rotation, 0, 0.25)
anim.rotTimer = anim.rotTimer + dt
if anim.rotTimer >= 0.1
or math.abs(anim.rotation) < 0.1 then
anim.rotation = 0
anim.rotPhase = 0
end
end
```
这个方案比单纯:
```lua
rotation = rotation * 0.9
```
更容易控制,也更接近 EASE_OUT + TRANS_BACK 的效果。
---
# 5. 按下和释放要分开处理
## 按下
按钮按下时只做压缩:
```lua
anim.targetScaleX = 0.92
anim.targetScaleY = 0.92
```
不要在按下瞬间就执行页面跳转,否则用户看不到点击反馈。
## 释放
只有释放位置仍在按钮内时才执行:
```lua
if pressedButton then
local hit = HitTest(x, y)
if hit == pressedButton then
StartBounceAnimation()
PlayClickSound()
Navigate()
else
ResetAnimation()
end
pressedButton = nil
end
```
设置页返回按钮使用了这个流程:
- Pages/Settings.lua:454-460
- Pages/Settings.lua:524-541
---
# 6. NanoVG 旋转缩放必须围绕中心
这是最重要的 NanoVG 绘制技巧之一。
错误写法:
```lua
nvgRotate(vg, angle)
nvgScale(vg, scaleX, scaleY)
nvgRoundedRect(vg, x, y, w, h, r)
```
这样会导致按钮绕左上角变形和旋转。
正确写法:
```lua
nvgSave(vg)
nvgTranslate(vg, centerX, centerY)
nvgRotate(vg, rotation * math.pi / 180)
nvgScale(vg, scaleX, scaleY)
nvgRoundedRect(vg, -w 0.5, -h 0.5, w, h, radius)
nvgRestore(vg)
```
设置页返回按钮:
```lua
nvgSave(vg)
nvgTranslate(vg, 170, 130)
nvgRotate(vg, back.rotation * math.pi / 180)
nvgScale(vg, back.scaleX, back.scaleY)
nvgRoundedRect(vg, -130, -50, 260, 100, 20)
nvgText(vg, 0, 0, "← 返回", nil)
nvgRestore(vg)
```
位置:
- Pages/Settings.lua:770-786
- Pages/GlobalShell.lua:231-253
- Pages/Desktop/HUD.lua:304-345
---
# 7. 不同页面共用动画逻辑
对于主营地、远征、市集共用的按钮,不要复制三份逻辑。
本项目把菜单按钮统一放在:
```lua
Pages/GlobalShell.lua
```
然后三个页面都调用:
```lua
GlobalShell.RenderMenuButton(...)
```
这样:
- 主营地菜单
- 远征地菜单
- 市集菜单
可以共享同一套动画。
这是本轮最值得复用的结构:
```lua
local menuAnim_ = {
scaleX = 1.0,
scaleY = 1.0,
rotation = 0,
targetScaleX = 1.0,
targetScaleY = 1.0,
rotPhase = 0,
rotTimer = 0,
rotImpulse = 0,
}
```
对应代码:
- Pages/GlobalShell.lua:20-183
- Pages/GlobalShell.lua:222-253
---
# 8. 暂停按钮也使用共享 HUD 动画
暂停按钮由:
```lua
Pages/Desktop/HUD.lua
```
统一绘制,因此主营地、远征、市集可以共用:
```lua
HUD.RenderHUD(...)
```
动画流程与菜单按钮一致:
```lua
tickPauseAnim(os.clock(), hovered, pressed)
```
然后围绕按钮中心绘制:
```lua
nvgSave(vg)
nvgTranslate(vg, bx + btnSize 0.5, by + btnSize 0.5)
nvgRotate(vg, pauseAnim_.rotation * math.pi / 180)
nvgScale(vg, pauseAnim_.scaleX, pauseAnim_.scaleY)
...
nvgRestore(vg)
```
位置:
- Pages/Desktop/HUD.lua:35-127
- Pages/Desktop/HUD.lua:302-345
---
# 9. 下拉菜单动画:面板和文字要分开
这次菜单文字消失的 bug 说明一个重要经验:
> 背景面板可以做透明度动画,但核心菜单文字不要依赖容易变成 0 的进度值。
容易出问题的写法:
```lua
nvgGlobalAlpha(vg, itemProgress)
nvgText(...)
```
当 itemProgress 为 0 或过小时,文字就完全不可见。
更稳妥的做法:
```lua
nvgGlobalAlpha(vg, panelAlpha)
nvgScale(vg, 0.98, 0.98)
nvgText(...)
```
让文字:
- 始终渲染
- 只做轻微缩放
- 不绑定逐项 alpha
当前菜单面板动画仍然可以使用:
```lua
local eased = 1 - (1 - animProgress) * (1 - animProgress)
local drawH = dropH * eased
```
但菜单项文字应该保持可见。
项目位置:
- Pages/GlobalShell.lua:261-328
---
# 10. 返回按钮不能用全屏阻塞或复杂弹窗
当前项目偏好:
- 不使用半透明全屏遮罩
- 不用复杂的确认弹窗阻塞操作
- 返回按钮直接提供明确的 press/click feedback
- 页面切换在释放确认后执行
推荐流程:
```text
MouseDown
↓
按钮缩小到 0.92
↓
MouseUp
↓
仍在按钮范围内
↓
播放弹跳和音效
↓
切换页面
```
如果鼠标移出按钮:
```text
MouseDown
↓
按钮缩小
↓
MouseMove 移出
↓
MouseUp
↓
取消点击,恢复默认状态
```
---
# 11. 当前动画覆盖结果
| UI 区域 | 悬停缩放 | 悬停旋转 | 按下缩小 | 点击弹跳 |
|---|---:|---:|---:|---:|
| 主菜单按钮 | 已有 | 已有 | 已有 | 已有 |
| 抓周卡牌 | 已有 | 已有 | 已有 | 已有 |
| 设置页开关 | 已有 | 已有 | 已有 | 已有 |
| 设置页返回 | 已补齐 | 已补齐 | 已补齐 | 已补齐 |
| 主营地菜单 | 已补齐 | 已补齐 | 已补齐 | 已补齐 |
| 远征地菜单 | 已补齐 | 已补齐 | 已补齐 | 已补齐 |
| 市集菜单 | 已补齐 | 已补齐 | 已补齐 | 已补齐 |
| 暂停按钮 | 已补齐 | 已补齐 | 已补齐 | 已补齐 |
| 下拉菜单 | 面板缩放 | 无需旋转 | 不适用 | 子项错序入场 |
---
# 12. 最终验证经验
每次修改 UI 动画后,必须完成:
```text
修改 Lua
↓
LSP 严格诊断
↓
确认 errors = 0
↓
UrhoX build
↓
检查多人配置未被覆盖
```
本轮验证结果:
```text
LSP:0 Error
构建:成功
多人模式:后台匹配 + 1 人秒开
```
最关键的经验可以浓缩成一句话:
> UI 动画由“状态机控制目标值”,由 lerp 推进当前值,由 nvgSave + 中心变换 + nvgRotate + nvgScale + nvgRestore 保证视觉稳定;背景动画和文字可见性必须分离,不能让文字依赖可能归零的动画 alpha。

