UI按键动画设计
一、本次反复失败的根因(三个坑)
| 现象 | 根因 |
| 按钮完全不可见(空白/小白点) | 入场动画把 scale/alpha 初始设 0,靠 Update 驱动 Tween 补到 1;但该模板页面级 Update 时序不可靠,补间不推进 → 永远停在 0 |
| 背景/按钮颜色反转 | 用了 nvgRGBA(r,g,b,255) 整数版,出现通道映射异常 |
| 矩形在、文字消失 | 文字色≈填充色;或 DrawStrokedText 描边盖住了填充 |
最核心的一条认知:NanoVGRender(即 _Render)是唯一每帧 100% 可靠的回调;Update 不可信。所有"每帧推进"的逻辑(动画、计时)必须放进 _Render。
二、三条铁律
- 静态可见:按钮构建时 scale=1, alpha=1,绝不用动画"让它出现"。动画只做 hover/press 增强。
- 帧内驱动:动画在 _Render 里用 os.clock() 算 dt + 逐帧 lerp;鼠标事件只写目标值,不直接改显示值。
- 浮点色:一律 nvgRGBAf(0..1);文字纯白配深色按钮,不用描边文字。
三、架构:双层状态 + 帧内 lerp
鼠标事件 ──只写目标──▶ AnimState{当前值, 目标值, 旋转相位}
_Render 每帧 ─os.clock()dt──▶ lerp(当前→目标) ──▶ nvgTranslate/Rotate/Scale 绘制- 分离 X/Y lerp 速度 = GDScript scale:x 0.2s / scale:y 0.35s(Y 慢→拉伸感)
- 旋转两阶段(flick 甩出 → return 弹回)= GDScript 的 rotation→target 再 →0 set_delay
- 随机方向、clamp(WIDTH_FULL_ROT/w,0.5,1.0) 尺寸自适应,原样移植
四、关键代码
状态 + 常量
lualocal WIDTH_FULL_ROT, LERP_SCALE_X, LERP_SCALE_Y, LERP_ROT, FLICK_DURATION = 300, 0.18, 0.10, 0.25, 0.1 local function lerp(a,b,t) return a+(b-a)*t end self._anim[i] = { scaleX=1,scaleY=1,rotation=0, targetScaleX=1,targetScaleY=1, rotPhase=0,rotTimer=0,rotImpulse=0 }
鼠标事件只改目标(悬停=缩放+随机旋转甩出)
lualocal ratio = math.max(0.5, math.min(1.0, WIDTH_FULL_ROT / btn.w)) local dir = (math.random() > 0.5) and 1 or -1 a.targetScaleX, a.targetScaleY = 1.0 + 0.2*ratio, 1.0 + 0.2*ratio a.rotPhase, a.rotTimer, a.rotImpulse = 1, 0, 5.0*ratio*dir -- 离开时设 1.0 / rotPhase=0
_Render 内逐帧推进(含 dt 钳制 + 目标回拉)
lualocal dt = os.clock() - self._lastClock; if dt > 0.1 then dt = 0.016 end; self._lastClock = os.clock() 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 a.rotation=lerp(a.rotation,0,LERP_ROT*0.5) else a.rotation=0 end end -- 弹跳(scale>1.15)结束后把目标拉回悬停值/1.0 for i,a in ipairs(self._anim) do if self.pressedIdx~=i and a.scaleX<1.15 then local st = self.hoveredIdx==i and 1.0+0.2*math.max(0.5,math.min(1.0,WIDTH_FULL_ROT/self.buttons[i].w)) or 1.0 a.targetScaleX, a.targetScaleY = st, st end end
绘制(中心 pivot + 浮点色 + 纯白文字)
luanvgSave(vg); nvgTranslate(vg,btn.cx,btn.cy); nvgRotate(vg,a.rotation*math.pi/180); nvgScale(vg,a.scaleX,a.scaleY) nvgBeginPath(vg); nvgRoundedRect(vg,-halfW,-halfH,btn.w,btn.h,28); nvgFillColor(vg,nvgRGBAf(r,g,b,1.0)); nvgFill(vg) nvgFillColor(vg,nvgRGBAf(1,1,1,1)); nvgText(vg,0,0,btn.label,nil) -- 纯白,不描边 nvgRestore(vg)
五、何时仍可用旧的自建补间引擎
- 单文件 Demo(自己掌控 Start+Update 主循环,Update 一定跑)→ 自建 Tween 可用。
- Boot/Router/EventBus 多页面模板(页面 Update 不一定每帧转发)→ 必须用本节 _Render 帧内方案。
一句话总结:可见性靠静态值,动画靠 _Render 帧内 lerp,颜色靠 nvgRGBAf——把"每帧一定执行"和"可能不执行"的逻辑彻底分开,是这次成功的根本。

