Defold 移动端适配与安全区

系统讲清 Defold 移动端适配:渲染投影的三种策略、GUI 节点的锚点与缩放模式、显示配置与横竖屏布局切换、安全区与刘海屏处理(window.get_safe_area 与 gui.set_safe_area_mode)、触屏输入、高 DPI 与子像素、真机调试与常见坑。

引言

移动端适配是 Defold 项目从「能跑」到「能上架」之间最容易被低估的一段路。同一份代码要在 18:9 的挖孔屏、4:3 的平板、折叠屏上都不出问题,靠的不是一套魔法参数,而是三件事分开处理:游戏世界怎么渲染、GUI 怎么排布、安全区怎么避让。本文按这三条线展开,并给出可直接抄的代码与项目设置。

前置阅读:GUI 系统与节点 、渲染脚本与渲染管线 。


目录


1. 适配要解决的四个问题

1. 内容量    屏幕更宽时是看到更多场景,还是把画面拉大?
2. 宽高比    16:9 与 4:3 之间如何过渡,是黑边、裁切还是缩放?
3. 界面排布  按钮贴屏幕边缘还是贴内容,换方向后怎么重排?
4. 遮挡区域  刘海、挖孔、圆角、手势条,哪些区域不能放关键 UI?

前两个问题由渲染投影决定,第三个由 GUI 锚点与缩放模式决定,第四个由安全区决定。三者互不替代:把投影设成「显示更多内容」并不能让按钮躲开刘海,把按钮锚到屏幕边缘也不能阻止场景被裁切。

踩坑:最常见的错误是「用一套 GUI 硬扛所有机型」——节点锚点全是默认值,结果在 iPad 上按钮跑到屏幕正中、在挖孔屏上被摄像头挡住。适配必须从设计阶段就把锚点和安全区当成必填项。


2. 分辨率与渲染投影

1. 默认行为

game.project 里的 display.width / display.height 定义的是逻辑分辨率,也是脚本与属性里使用的坐标系。默认渲染脚本的行为是:无论窗口多大,始终把这块逻辑区域拉伸铺满窗口。

[display]
width = 960
height = 640
high_dpi = 0

这在窗口被拉成不同宽高比时会变形(圆变椭圆)。要改,向渲染脚本发消息切换投影模式:

msg.post("@render:", "use_stretch_projection")    -- 拉伸铺满(默认,会变形)
msg.post("@render:", "use_fixed_fit_projection")  -- 等比缩放,尽量填满,可能露黑边
msg.post("@render:", "use_fixed_projection", { zoom = 2 })  -- 固定缩放倍数

2. 三种策略的取舍

消息行为适合
use_stretch_projection铺满窗口,宽高比失真极少用,宽高比差异小的场景
use_fixed_fit_projection等比缩放,保证逻辑区域全可见现代 HD 游戏,宁可有黑边
use_fixed_projection固定倍数放大,视野随之变化像素风复古游戏

use_fixed_fit_projection 保证「至少看到逻辑分辨率的全部内容」,多出来的部分显示更多场景,不会裁切。用 Camera 组件也能达到同样效果:勾选 Orthographic Projection 并把 Orthographic Zoom 设为目标倍数。

3. 像素风的两个必改项

[graphics]
texture_profiles = /builtins/graphics/default.texture_profiles

[display]
width = 320
height = 200

再加两处设置,缺一不可:

- Texture Profiles 里把过滤设为 nearest,否则放大后模糊
- game.project 里关掉 Subpixels,精灵永远对齐整像素

3. GUI 缩放模式与锚点

1. 三个节点属性

每个 GUI 节点有三个决定适配行为的属性:

Pivot    节点中心点,缩放与旋转围绕它进行
Anchor   场景或父节点被拉伸时,节点的位置如何跟随(Left/Right/Top/Bottom/None)
Adjust   场景或父节点被调整时,节点自身尺寸如何变化

Anchor 是位置策略,Adjust 是尺寸策略,二者独立。一个常见的错误是只设了 Anchor 忘了 Adjust,结果按钮位置对了但尺寸还是老的。

2. Adjust Mode 的三种取值

模式行为典型用途
gui.ADJUST_FIT等比缩放到能装下,保持宽高比图片、图标,绝不希望变形
gui.ADJUST_ZOOM等比缩放到铺满,可能超出边界全屏背景
gui.ADJUST_STRETCH直接拉伸填满,允许变形纯色条、边框、进度条底

运行时改单个节点:

gui.set_adjust_mode(gui.get_node("bg"), gui.ADJUST_ZOOM)
gui.set_xanchor(gui.get_node("hp_bar"), gui.ANCHOR_LEFT)
gui.set_yanchor(gui.get_node("hp_bar"), gui.ANCHOR_TOP)

-- 读取
local mode = gui.get_adjust_mode(node)
local w, h = gui.get_size(node)

踩坑:Anchor 只有在场景或父节点边界变化时才起作用。如果父节点自身是 FIT 且尺寸固定,子节点设 Anchor 也不会有任何变化——先确认父级的 Adjust 是否会变。

3. 尺寸模式

节点还有 Size Mode,决定尺寸来源:gui.SIZE_MODE_MANUAL 手动设,或按贴图/父节点自动。做自适应进度条时,通常让底图 STRETCH、前景条 MANUAL 并用 gui.set_size 按比例设宽:

local pct = self.hp / self.max_hp
local full_w = 200
gui.set_size(gui.get_node("hp_fill"), vmath.vector3(full_w * pct, 16, 0))

4. 显示配置与布局切换

1. Display Profiles

Display Profiles 文件描述一组目标分辨率 + 设备型号,GUI 场景可以针对每个 Profile 存一套节点属性覆盖。默认文件是 builtins/render/default.display_profiles,含 Landscape(1280×720)与 Portrait(720×1280)两个 Profile,不带设备型号,因此匹配任意机型。

[display]
display_profiles = /builtins/render/default.display_profiles
dynamic_orientation = 1

勾选 Dynamic Orientation 后,引擎在设备旋转时自动切换到匹配方向的布局。

2. 布局是属性覆盖,不是另一套节点

GUI 里的「Layout」只覆盖属性,不能增删节点。想在某布局里隐藏节点,只能移到屏幕外或用脚本逻辑处理。被覆盖的属性在编辑器里显示为蓝色,可一键重置。

3. 手动切换与消息

关掉 Auto Layout Selection 后由脚本控制:

function init(self)
    local ok = gui.set_layout("Portrait")
    if not ok then print("no Portrait layout") end
    local layouts = gui.get_layouts()
    for id, size in pairs(layouts) do print(id, size.x, size.y) end
end

function on_message(self, message_id, message, sender)
    if message_id == hash("layout_changed") and message.id == hash("Portrait") then
        -- 切换到竖屏布局后的额外逻辑
    end
end

布局真正发生变化时才会发 layout_changed。渲染脚本还会收到 window_resized 消息,message.width / message.height 是新的窗口尺寸——这是重算相机与视口的地方。


5. 安全区与刘海屏

1. 内置安全区 API

window.get_safe_area() 返回当前窗口的安全区矩形,字段齐全:

local sa = window.get_safe_area()
-- sa.x, sa.y, sa.width, sa.height
-- sa.inset_left, sa.inset_top, sa.inset_right, sa.inset_bottom
print(sa.inset_top)   -- 顶部被刘海/状态栏占用的像素数

坐标是物理像素,需要除以 window.get_display_scale() 才能换算到逻辑坐标:

local scale = window.get_display_scale()
local top_inset = window.get_safe_area().inset_top / scale

2. GUI 安全区模式

GUI 场景可以声明安全区模式,引擎自动把 insets 计入节点调整:

gui.set_safe_area_mode(gui.SAFE_AREA_BOTH)   -- 四边全部避让
-- 其他取值:SAFE_AREA_NONE / SAFE_AREA_LONG / SAFE_AREA_SHORT

项目级默认值在 game.project:

gui.safe_area_mode
  none  默认,忽略所有 insets
  long  横屏避让左右,竖屏避让上下
  short 横屏避让上下,竖屏避让左右
  both  四边全避让

long 与 short 的名字来自「屏幕的长边 / 短边」:long 指避让长边方向的 insets,short 指避让短边方向的 insets。

3. 更细的控制:safearea 扩展

需要拿到圆角半径或自己画背景时,用 extension-safearea:

local insets = safearea.get_insets()      -- 四个方向的 insets
local radius = safearea.get_corners_radius()  -- 圆角半径
safearea.set_background_color(vmath.vector4(0, 0, 0, 1))

踩坑:window.get_safe_area() 返回的是物理像素,而 GUI 与脚本里的位置是逻辑像素。忘记除以 window.get_display_scale() 会导致在 high_dpi 机型上 inset 被高估一倍。


6. 触屏输入适配

1. 触摸事件

触屏与鼠标在 Defold 里走同一套 on_input,用 action_id 区分。多点触控用 action.id(触点编号)跟踪:

function on_input(self, action_id, action)
    if action_id == hash("touch") and action.pressed then
        self.touch_id = action.id
        self.start = vmath.vector2(action.x, action.y)
    elseif action_id == hash("touch") and action.released then
        if action.id == self.touch_id then
            local dx = action.x - self.start.x
            if math.abs(dx) > 40 then self:swipe(dx) end
        end
    end
end

action.x / action.y 已经是逻辑坐标,无需再换算,这一点和 window.get_safe_area() 不同,容易搞混。

2. 按钮的触摸区域

GUI 脚本里用 gui.pick_node 判断触点是否落在节点上,注意节点可能被安全区推走:

function on_input(self, action_id, action)
    if action_id == hash("touch") and action.pressed then
        if gui.pick_node(gui.get_node("btn_play"), action.x, action.y) then
            msg.post("#", "play")
        end
    end
end

小屏幕上手指比像素粗,交互目标最小边建议不小于 44 逻辑像素;用一张透明贴图把点击区域撑大,比把按钮画大更好。


7. 横竖屏与高 DPI 实战

1. 高 DPI 与视网膜屏

[display]
high_dpi = 1

开启后,支持的屏幕上会创建双倍分辨率的后备缓冲:脚本与属性里的坐标仍是逻辑分辨率,渲染分辨率翻倍。1 倍尺寸的素材看起来不变,而把高清素材缩到 0.5 使用就会在视网膜屏上更清晰。

2. 横竖屏切换的完整流程

1. game.project 勾选 Dynamic Orientation
2. 在 GUI 场景里建 Landscape / Portrait 两个 Layout
3. 监听 layout_changed 更新与方向相关的逻辑
4. 在渲染脚本里监听 window_resized 重算相机与视口
5. 场景本身(非 GUI)的投影在切换后要重新下发 use_fixed_fit_projection

第 5 步最容易被漏:GUI 布局管理器会自动重排节点,但游戏世界内容默认仍以 stretch-fit 投影渲染。方向切换后要么重新发投影消息,要么换用 Camera 组件由引擎按相机参数处理。

3. 折叠屏与平板

local info = sys.get_sys_info()
print(info.device_model, info.system_name, info.system_version)

local w, h = window.get_size()
local aspect = w / h
if aspect > 2.0 then
    -- 超宽屏,可能需要额外留白或调整视野
end

Display Profiles 的 Device Models 字段可以按机型前缀匹配(iPhone10 会匹配 iPhone10,*),但只有 Android 与 iOS 会返回设备型号,其他平台返回空串。


8. 真机调试与常见坑

1. 编辑器内模拟分辨率

Debug 菜单里的 Simulate Resolution 可以直接把运行中的窗口改成某个机型的尺寸,实时看效果。这是成本最低的验证方式,但它不模拟安全区与圆角。

2. 常见坑清单

- 只设 Anchor 不设 Adjust,位置对了尺寸还是错的
- 忘除 display_scale,inset 在 high_dpi 机型上翻倍
- 布局切换后没重发投影消息,场景内容仍是旧投影
- 用 STRETCH 处理图标,在宽屏上被拉扁
- 交互热区小于 44 逻辑像素,真机上点不中
- 把关键按钮放在安全区外,被刘海或手势条遮住
- 用固定像素写死 UI 位置,换分辨率就错位

3. 一套可复用的检查顺序

先看游戏世界:投影消息是否按宽高比切换?
再看 GUI:每个节点的 Anchor / Adjust 是否明确?
再看安全区:safe_area_mode 是否设置,关键 UI 是否在内?
最后看输入:热区尺寸与坐标是否用逻辑像素?

按这个顺序过一遍,绝大多数「在我手机上没问题」的适配 bug 都能提前暴露。


9. 速查表

需求做法备注
等比缩放不变形msg.post("@render:", "use_fixed_fit_projection")可能露黑边
固定倍数放大use_fixed_projection, { zoom = 4 }像素风
像素清晰Texture Profiles 设 nearest + 关 Subpixels两者都要
节点保持宽高比gui.set_adjust_mode(n, gui.ADJUST_FIT)图标、图片
节点铺满gui.set_adjust_mode(n, gui.ADJUST_ZOOM)背景
节点贴边gui.set_xanchor(n, gui.ANCHOR_LEFT)与 yanchor 独立
取安全区window.get_safe_area()返回物理像素
物理转逻辑除以 window.get_display_scale()常被遗漏
GUI 避让刘海gui.set_safe_area_mode(gui.SAFE_AREA_BOTH)项目级 gui.safe_area_mode
圆角半径safearea.get_corners_radius()需 extension-safearea
自动横竖屏勾 Dynamic Orientation + 建双 Layout监听 layout_changed
窗口变化渲染脚本收 window_resizedmessage.width/height
高清屏high_dpi = 1逻辑分辨率不变
机型信息sys.get_sys_info().device_model仅 Android / iOS 有值

一句话记忆:渲染投影管「看到多少」,GUI 锚点与缩放管「排在哪、多大」,安全区管「别被挡住」——三条线分开配,再按「世界 → GUI → 安全区 → 输入」的顺序逐个验证。


相关阅读

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「defold」更多文章

  1. Defold 行为树与游戏 AI 决策
  2. Defold 材质与着色器语言详解
  3. Defold HTML5 导出与 Web 性能优化