下载 App

按键 UI 动画

08/2059 浏览综合
这套项目是 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。
【更新】增加了一些任务截图
【更新】增加了一些任务
我从故事便签墙中,看到了大多数玩家都在和野兽、疾病博弈中倒下 我这两天补充了一些任务,有关合成物品的为主 在此感谢大伙儿的积极反馈
1 赞
4 回复
【更新】新增驿站+远征地截图
【更新】新增驿站+远征地
官方
2 回复
任务
当前任务栏共设计了 2 个阶段、7 个任务: --- ### 襁褓 · 初识世界(5个) | # | 任务名 | 描述 | 完成条件 | |---|--------|------|----------| | 1 | 看见万物 | 开启你的人生初体验 | 初始体验包开出 3 张牌 | | 2 | 伸手触碰 | 拖动任意一张牌 | 拖动 1 次 | | 3 | 万物相遇 | 把一张牌放到另一张牌上 |
官方
1 赞
【更新】驿站+远征地截图
【更新】驿站+远征地
可以抢夺别人(NPC)的地盘、开辟新的地块 游戏中很多卡牌还缺插画,如果你有一幅自创的画,可以发到该游戏论坛,我会把它添加作为某张卡牌插画
2 赞
拖到合成目标才留位
错误原因 我在上一轮加回收功能时,为了"修位置回正"加了 else 分支:当牌既没拖到回收区、也没合成目标时,强制把牌弹回原位。这破坏了 Stacklands 的基本交互——拖到空地就该留在那里。 -- ❌ 错误版本(多余的 else 导致拖到空地也弹回) if inRecycle and not card.locked then ...回收溶解... elseif self._mergeTar
官方
UI 按键动画设计经验
从 GDScript (Godot Tween) 移植到 UrhoX Lua + raw NanoVG 的完整经验总结。 版本: v0.3.0 | 日期: 2026-07-28 重要:第 1~9 节是"设计理论 + 自建补间引擎"思路(适合理解原理)。 但在《抓周》这类 Boot/Router/EventBus 模板里实测落地时,靠 Update 驱动补间会让按钮不可见、nvgRGBA 整数色会
官方
【待解决】拍卖物品持久化
从你给的便签墙案例看,拍卖现在的 account_not_ready 很可能是同一类问题:异步数据域加载完成前,客户端已经允许用户发起业务操作;但这里还叠加了账户服务初始化时序问题。 这次只分析,不改代码。 一、拍卖当前实际链路 当前流程大致是: 服务端 Start() ↓ 初始化 ServerAccountService ↓ 客户端 ServerReady ↓ 客户端请求: ├─ MAR
官方
【更新】平台新版本发布游戏变成了“预约”截图
【更新】平台新版本发布游戏变成了“预约”
今天更新之后,一直奇怪怎么看不到“热度”,变成了“预约” 后面才知道,需要去这里改为 提供游玩 【更新】 1、新增便签墙功能,每一局都有小故事 2、增加了自定义头像
3 赞
1 回复
02:41
【新上游戏】休闲卡牌摸鱼截图
【新上游戏】休闲卡牌摸鱼
推荐电脑端 游戏中很多卡牌还缺插画,如果你很好的一幅画,可以发到该游戏论坛,我会把它添加作为某张卡牌插画
2 赞
【游戏特色】儿童手工绘制插画,接牛来地气截图
【游戏特色】儿童手工绘制插画,接牛来地气
1、更新了一些卡包插画 2、优化了手机端按键失灵 游戏中很多卡牌还缺插画,如果你很好的一幅画,可以发到该游戏论坛,我会把它添加作为某张卡牌插画
4 赞
3 回复