NanoVG 多分辨率 UI 适配:背景锚定与文字图标对齐实战

修改于05/0890 浏览开发心得
以竖屏魔塔游戏的 HUD 系统为例,记录在不同手机屏幕上保持 UI 背景、文字、图标三者对齐的完整方案。
horizontal linehorizontal line
问题:为什么 UI 在换一台手机就错位了?用 NanoVG 直接画 UI 时,最常遇到的问题是:
  • 背景图在宽屏手机上拉伸变形,窄屏手机上两侧露底
  • 文字在高分屏上变小/模糊,在低分屏上又溢出胶囊
  • 图标和文字的间距在不同分辨率下忽大忽小
  • 右上角的信息被系统胶囊控件遮挡
horizontal linehorizontal line
TapTap
第一层:物理分辨率 → 逻辑分辨率所有布局计算的起点,是把物理像素转换为逻辑像素。lua
复制function HandleNanoVGRender(eventType, eventData)
    local physW = graphics:GetWidth()     -- 物理像素宽(如 1170)
    local physH = graphics:GetHeight()    -- 物理像素高(如 2532)
    local dpr   = graphics:GetDPR()       -- 设备像素比(如 3.0)
    local screenW = physW / dpr           -- 逻辑宽(如 390)
    local screenH = physH / dpr           -- 逻辑高(如 844)
    nvgBeginFrame(vg_, screenW, screenH, dpr)
    -- 之后所有坐标都用逻辑像素
end
关键点:nvgBeginFrame 的第三个参数传 dpr,NanoVG 会自动处理高清渲染。之后所有绘制坐标都用逻辑像素,不需要手动乘 DPR。手机物理分辨率DPR逻辑分辨率iPhone SE750×13342.0375×667iPhone 141170×25323.0390×844小米 141440×32003.0480×1067逻辑分辨率差异不大,但仍然不同。所以布局不能写死坐标,必须相对计算。
第二层:统一布局计算——一个函数定义所有区域不同 UI 元素散落在不同函数里各自计算位置,很容易互相冲突。解决方案是一个集中的布局函数,输入屏幕尺寸,输出所有区域的边界:lua
复制function ComputeLayout(screenW, screenH)
    local topBarH    = 80                  -- 顶部资源栏高度(固定)
    local bottomNavH = 60                  -- 底部导航栏高度
    local panelH     = math.floor(screenH * 0.35) -- 面板高度 = 屏幕 35%
    -- 地图区域 = 屏幕减去顶部和底部
    local mapAreaH = screenH - topBarH - bottomNavH - panelH
    return {
        topBarH    = topBarH,
        bottomNavH = bottomNavH,
        panelH     = panelH,
        mapAreaY   = topBarH,
        mapAreaH   = mapAreaH,
    }
end
所有绘制函数通过 layout.bottomNavH、layout.panelH 获取位置,不各自硬编码。改一个值,全局联动。
第三层:背景图适配——等比缩放 + 锚点定位背景装饰图最容易出问题:原图尺寸固定,屏幕尺寸不固定。错误做法:直接拉伸到屏幕宽度lua
复制-- ❌ 图片变形
nvgImagePattern(vg_, 0, 0, screenW, screenH, 0, bgImage, 1.0)
正确做法:按宽度等比缩放 + 锚定到参考点lua
复制-- ✅ 保持纵横比,锚定到面板顶部
local IMG_W, IMG_H = 1029, 840           -- 原始素材尺寸
local drawW = screenW * 0.96             -- 宽度占屏幕 96%
local drawH = drawW * IMG_H / IMG_W      -- 高度等比计算
local drawX = (screenW - drawW) / 2      -- 水平居中
-- 关键:Y 坐标基于面板顶部锚定,不是基于屏幕顶部
local drawY = panelY - 30               -- 装饰图上沿在面板上方 30px
local paint = nvgImagePattern(vg_, drawX, drawY, drawW, drawH, 0, bgImage, 1.0)
nvgBeginPath(vg_)
nvgRect(vg_, drawX, drawY, drawW, drawH)
nvgFillPaint(vg_, paint)
nvgFill(vg_)
核心思路:维度策略为什么宽度screenW * 百分比跟随屏幕宽度自适应高度drawW * 原始比例保持不变形水平位置(screenW - drawW) / 2始终居中垂直位置锚定到 panelY / navY和上层 UI 对齐,不随屏幕高度漂移多层装饰图也用同样的模式,各自锚定到不同的参考线:lua
复制-- 装饰图 1:锚定到面板顶部
local drawY1 = panelY - DECO1_ABOVE_PANEL
-- 装饰图 2:锚定到导航栏顶部
local drawY2 = navY - DECO2_ABOVE_NAV
这样无论屏幕多高,背景图始终贴着它该贴的 UI 元素。
第四层:文字对齐——先测量,再定位NanoVG 文字最大的坑:不同内容宽度不同。“Lv.5” 和 “Lv.128” 的宽度差很多,如果胶囊是固定宽度,文字就会溢出或偏心。方案 A:胶囊宽度跟随文字适用于信息标签(地图名、楼层等):lua
复制-- 1. 先测量文字实际宽度
local bounds = {}
nvgFontSize(vg_, 13)
nvgTextBounds(vg_, 0, 0, mapLabel, bounds)
local textW = (bounds[3] or 50) - (bounds[1] or 0) + 2
-- 2. 胶囊宽度 = 文字宽度 + 左右内边距
local capsuleW = textW + 16
local capsuleH = 22
-- 3. 从右边缘向左扩展(右对齐)
local capX = screenW - padRight - capsuleW
local capY = padTop
-- 4. 画胶囊 + 文字
nvgRoundedRect(vg_, capX, capY, capsuleW, capsuleH, capsuleH / 2)
nvgTextAlign(vg_, NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE)
nvgText(vg_, capX + 8, capY + capsuleH / 2, mapLabel)
效果:文字长了胶囊就宽,文字短了胶囊就窄,始终紧凑。右边缘固定,向左生长。方案 B:固定胶囊,文字自动缩小适用于资源数值(金币、体力等):lua
复制-- 1. 胶囊宽度固定
local capW = 103
-- 2. 计算文字可用宽度
local rightEdge = capX + capW - 6       -- 右内边距 6px
local leftEdge  = capX + iconSize - 8   -- 图标右侧
local maxTextW  = rightEdge - leftEdge
-- 3. 测量文字,超宽则缩小字号
nvgFontSize(vg_, 16)
nvgTextBounds(vg_, 0, 0, valueText, bounds)
local textW = (bounds[3] or 0) - (bounds[1] or 0)
if textW > maxTextW and textW > 0 then
    local newSize = math.floor(16 * maxTextW / textW)
    newSize = math.max(9, newSize)       -- 最小 9 号,再小就看不清了
    nvgFontSize(vg_, newSize)
end
-- 4. 居中绘制
nvgTextAlign(vg_, NVG_ALIGN_CENTER + NVG_ALIGN_MIDDLE)
nvgText(vg_, capX + capW / 2, midY, valueText)
效果:数值从 “99” 涨到 “999999” 时,字号自动缩小但不会溢出胶囊。
第五层:图标与文字的相对对齐图标和文字放在一起时,最常见的错位是垂直方向不对齐drawIcon 函数:中心对齐lua
复制local function drawIcon(img, cx, cy, size, alpha)
    -- 关键:从中心点计算左上角
    local x = cx - size / 2
    local y = cy - size / 2
    local paint = nvgImagePattern(vg_, x, y, size, size, 0, img, alpha)
    nvgBeginPath(vg_)
    nvgRect(vg_, x, y, size, size)
    nvgFillPaint(vg_, paint)
    nvgFill(vg_)
end
所有参数都是中心坐标(cx, cy),不是左上角。这样图标和文字可以共享同一个 cy:lua
复制local cy = capsuleY + capsuleH / 2       -- 胶囊垂直中心
drawIcon(icon, iconCX, cy, 18, 1.0)     -- 图标垂直居中
nvgTextAlign(vg_, NVG_ALIGN_LEFT + NVG_ALIGN_MIDDLE)  -- 文字也垂直居中
nvgText(vg_, iconCX + 12, cy, "128")    -- 共享同一个 cy
按钮按压缩放:保持中心不动lua
复制local BTN_PRESS_SCALE = 0.85
local scale = isPressed and BTN_PRESS_SCALE or 1.0
local drawSize = baseSize * scale
-- 关键:缩放后重新从中心计算位置
local drawX = cx - drawSize / 2     -- 中心点不变,四周等比收缩
local drawY = cy - drawSize / 2
如果用左上角计算,按压时图标会「向右下角缩」。用中心点计算,图标原地缩放。
第六层:竖屏安全区——避让系统控件竖屏手机右上角通常有系统胶囊控件(状态栏、时间、电量)。UI 元素贴着屏幕边缘放,大概率被遮挡。lua
复制-- ❌ 贴边放置,被系统控件遮挡
local capX = screenW - 8 - capsuleW
-- ✅ 预留安全边距
local padRight = 80    -- 右侧留 80px 避让胶囊控件
local padTop   = 8
local capX = screenW - padRight - capsuleW
对于底部导航栏,同样的原则——Tab 按钮不贴底部边缘:lua
复制local navY = screenH - bottomNavH      -- 导航栏顶部 Y
-- 底部自然有 bottomNavH 的空间,不用额外处理
-- 但左侧要给虚拟方向键让位:Tab 按钮右对齐
local tabStartX = screenW - totalTabW - 10
第七层:三等分布局——不写死位置顶部三个资源胶囊(体力、金币、蘑菇)需要在任意屏幕宽度下均匀分布:lua
复制-- ❌ 硬编码位置,只在 390px 宽的屏幕上正确
local positions = { 65, 195, 325 }
-- ✅ 三等分屏幕宽度
local sectionW = screenW / 3
for i = 1, 3 do
    local centerX = sectionW * (i - 0.5)  -- 每段的中心
    -- centerX 在 390px 屏幕上:65, 195, 325
    -- centerX 在 480px 屏幕上:80, 240, 400
    -- 自动适配!
end
同理,两列属性网格也用比例计算列宽:lua
复制local pad = 58
local colW = (panelW - pad * 2 - gap) / 2   -- 动态列宽
for i, attr in ipairs(attrs) do
    local col = (i - 1) % 2
    local x = pad + col * (colW + gap)       -- 相对定位
end
完整绘制顺序绘制顺序决定了层叠关系。从底到顶:1. 木纹底图(平铺填充,最底层)
2. 装饰图 1(锚定 panelY,覆盖面板上沿)
3. 装饰图 2(锚定 navY,覆盖导航栏上沿)
4. 地图名胶囊(右上角,padRight 避让)
5. 地图角落信息(左下角,从下往上叠放)
6. 面板内容(属性/装备/美食,带 scissor 裁剪)
7. 底部导航栏 Tab 按钮
8. 弹窗层(装备详情、邮件,最顶层)
每一层的 Y 坐标都基于 navY 或 panelY 计算,而这两个锚点由 ComputeLayout() 统一计算。改屏幕尺寸 → 锚点自动变 → 所有层跟着变。
总结:对齐三原则原则做法反面统一锚点所有 UI 元素基于 ComputeLayout() 返回的锚点定位每个函数各自计算,改一个另一个错位先量后画文字先 nvgTextBounds 测量,再决定容器大小或缩小字号写死容器宽度,文字溢出或留白中心对齐图标和文字共享 cy(垂直中心),用 NVG_ALIGN_MIDDLE用左上角对齐,图标和文字各自偏移掌握这三个原则,同一套代码就能在 iPhone SE 到折叠屏上保持一致的 UI 表现。
9
1
9