下载 App

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

修改于05/08123 浏览开发交流
以竖屏魔塔游戏的 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 表现。
01:29
官方阅,申请积分计划2,希望官方继续支持我完成这款游戏,截图
官方阅,申请积分计划2,希望官方继续支持我完成这款游戏,
视频内容只是一部分, 本游戏耗时3个月,目前完成进度百分之80, 是生存游戏,进入修仙界,走剧情找到宗门加入, 这时游戏才是真正开始, 提升实力境界后就可以变强,获取功法法术法宝, @视频内是游戏里挑战妖族入侵一种主流玩法,游戏就是围绕着这个来进行, 还有很多地方可以战斗,获得资源修炼变强,比如去藏书阁学习功法, 去法宝楼抽法宝,每一个宗门数十个法宝抽取 去修仙界探索,获得各自的资源,可能遇见妖兽
1 赞
3 回复
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
118 赞
37 回复
03:06
为了不被斩杀,我只能做到这些了截图
为了不被斩杀,我只能做到这些了
之前一直申请不到计划3,之前说太花哨,后面说体量完成度不足,在我一番小作文下,编辑终于给了我解答,什么原生质感,玩家开局体验啥的。 本来计划2也够用,准备以后再优化了,结果计划2赠送的积分要砍半了,学生党实在消费不起,于是只能再冲一波计划3了 从周六中午到现在一天半时间,优化了一遍角色卡,战斗卡,还有抽卡界面,优化了新手教程,该说不说确实比之前要舒服些。 前半部分为新版,后半部分为旧版。 明天再次
12 赞
3 回复
本地部署 AI 对接 TapMaker 指南 —— 以 CodeBuddy 为例V1.1截图
本地部署 AI 对接 TapMaker 指南 —— 以 CodeBuddy 为例V1.1
写在前面 大家好,我是橘猫,和你一样也是TapMaker的萌新玩家。在"全能神"天哥的悉心指导下,我顺利完成了本地开发环境的部署。经过这段时间的实战测试,已经成功打通了「本地开发 → 同步至TapMaker」的完整流程。 以下内容全部来自我的个人实操经验,旨在为同样在探索本地部署的朋友提供一个参考。每个人的电脑环境、网络情况、AI工具版本都不尽相同,文中的步骤仅供交流学习。如果你照着操作后遇到问题
31 赞
14 回复
冰雹猜想(证明)截图
冰雹猜想(证明)
突然发现这个太麻烦了,刚又想了一个很简单的,但不想搞了,困。
3 赞
00:45
TapTap制造本地开发模式保姆级教程截图
TapTap制造本地开发模式保姆级教程
1.你可以使用 Codex、Cursor、Claude Code 等支持 MCP 的 AI 工具 最简单的就是腾讯的WorkBuddy 2.去官网下载WorkBuddy 3.对WorkBuddy说:我要安装个Agent 我给你安装方法 一、安装 Maker MCP npx -y @taptap/maker install --ide codex,cursor,claude 部分 Agent 安装
11 赞
6 回复
新人避坑指南第一期(核心必看)截图
新人避坑指南第一期(核心必看)
新手制作游戏项目三大避坑准则 一、前期地图规范要求 开启新项目之后,必须清晰交代需求:明确项目内容、运行环境、角色出生点位置,再交由助手制作第一张地图。 第一张地图禁止直接预览,先将生成内容存入素材库,核对样式是否符合自身预期。 核心判定方法:单独生成第一张地图预览图,第一张地图决定整体全局架构,UI、UX整体风格全部由它锁定。如果第一张地图的画面、UI、UX不符合自己的设想,立刻新建项目,不要在
14 赞
5 回复
【UI教程】如何将已有的效果图应用为实际的UI界面截图
【UI教程】如何将已有的效果图应用为实际的UI界面
在经过了一些折腾和比较折磨的尝试后,我总算是摸清楚了嗒啦啦怎么把UI效果图转换为实际游戏中的UI组件,今天就给大家带来这部分的经验分享。 tips:1.本篇教程需要各位掌握最基本的ps使用,具体的操作部分我会大致讲解,但仍需各位自行学习ps的基本操作,部分可以由ai代劳的环节我会注明。 2.本篇教程需要在以我前两篇教程作为前置环节,请在阅读这两篇前置教程后再进行本篇教程内容的学习 在按照前
10 赞
4 回复
00:45
一个在做的小项目截图
一个在做的小项目
这是7月11 创建的项目 内容不是很多,但是因为是菜鸡小白,所以很多东西进度很慢。 开发了7天,进度也仅仅才达到这样。 我是用慢慢推进的方式一点点做的,从开始界面,然后一点点往里面推。 点开的都是开发得差不多了,没点的那就是没做。 战斗场景也没做, 缺的东西很多。 但是都在规划中,我的思路是先做好UI布局,然后再完善内部数值系统。 玩法也是比较常用的刷宝玩法。 刷宝养成类 所以没有太复杂的系统,所
1 赞
5 回复
别再烧积分了!从每次 2000 到每次 50 的血泪经验截图
别再烧积分了!从每次 2000 到每次 50 的血泪经验
适用场景:所有在用 TapTap Code 开发游戏的小白 难度:★☆☆☆☆ 背景最近群里总有人"炫耀"一次操作花了几千积分,说实话这不是值得高兴的事——这是在告诉所有人你的开发方式有根本性问题。我也走过这条弯路,所以来说说怎么改。 积分消耗的本质是什么积分 = AI 处理的 token 数量 = 你发给 AI 的内容 + AI 回给你的内容所以烧积分的本质只有两种: 你让 AI 做了大量无效/重
精华
64 赞
20 回复