1. 项目概述:为什么横竖屏适配是移动开发的“必修课”
最近在社区里看到不少刚接触Godot做移动游戏的朋友,都在问同一个问题:游戏在手机上运行时,如果用户旋转了设备,画面要么被拉伸变形,要么直接黑边,体验非常糟糕。这其实触及了移动游戏开发中一个基础但至关重要的环节——屏幕适配,尤其是横竖屏的动态切换。我刚开始用Godot做手游时,也在这个问题上栽过跟头,后来花了些时间研究,才把一套稳定可靠的方案跑通。
简单来说,这个项目要解决的核心痛点就是:如何让你的Godot游戏,在用户旋转手机时,能自动、平滑、正确地重新布局和渲染,保证游戏核心内容始终以最佳比例和清晰度呈现,而不是出现拉伸、裁剪或功能错位。这不仅仅是把画面“转过来”那么简单,它涉及到视口(Viewport)的尺寸管理、UI控件的锚点(Anchor)与边距(Margin)设置、游戏逻辑对屏幕方向变化的响应,以及如何优雅地处理不同设备那千奇百怪的宽高比。
为什么说这是必修课?因为移动设备的多样性远超PC。玩家的手机可能是细长的全面屏,也可能是接近正方形的平板。如果游戏只适配了横屏16:9,当在一个21:9的竖屏手机上运行时,两侧可能会出现巨大的黑边,或者UI被挤到屏幕外。一个成熟的、面向移动平台的项目,必须从一开始就考虑好这套自适应机制。接下来,我会基于Godot 4.1.3,拆解从原理到实现的完整流程,并附上可以直接集成到你项目中的代码模块。
2. 核心原理与设计思路拆解
在动手写代码之前,我们必须先理解Godot引擎处理屏幕和窗口的核心逻辑。很多适配问题,根源在于对这几个关键概念理解不透彻。
2.1 Godot的显示层级:Window、Viewport与CanvasLayer
想象一下你的游戏画面是一个多层舞台。最底层是Window,它对应着操作系统提供的那个窗口,在移动端就是整个手机屏幕。Window有它自己的像素尺寸,比如1080x2340。
Window里面装着Root Viewport(根视口)。你可以把它理解为一个画布,游戏世界的一切最终都要绘制在这个画布上。默认情况下,这个根视口会撑满整个Window。
在Viewport之上,是CanvasLayer。这是Godot UI系统(Control节点)的舞台。每个CanvasLayer都有一个独立的变换(缩放、偏移),这使得UI可以独立于游戏世界进行布局。我们常说的UI适配,主要工作就是在CanvasLayer这个层面上进行的。
横竖屏切换时,发生变化的首先是Window的尺寸(宽和高交换了)。这个变化会向上传递,改变Root Viewport的尺寸,进而影响到所有基于Viewport尺寸进行计算的逻辑。我们的任务,就是监听这个尺寸变化信号,并相应地调整游戏世界和UI的布局策略。
2.2 适配策略选择:拉伸、扩展与黑边
面对不同的屏幕比例,通常有三种基础的适配策略:
- 拉伸(Stretch):无视宽高比,强行将游戏内容拉伸到填满整个屏幕。这会导致圆形变椭圆,人物变胖或变瘦,是最不推荐的方式。
- 扩展(Expand):保持游戏内容的原始宽高比,但允许看到比设计分辨率更多或更少的游戏场景内容。这就像调整相机取景范围,适合一些场景无边界的游戏(如太空射击游戏)。
- 黑边(Black Bars):保持游戏内容的原始宽高比和可见范围,在屏幕多余的空间显示黑边(或装饰性边框)。这能保证所有玩家看到完全一样的游戏画面,是许多主机游戏移植到PC时采用的策略。
对于大多数2D游戏,尤其是UI复杂的游戏,“扩展”策略结合智能的UI布局是更常见和实用的选择。这意味着我们的游戏世界(背景、角色等)可以有一定的伸缩,但必须在一个安全范围内;而UI(按钮、血条、分数)则需要通过锚点和边距,始终固定在屏幕的特定位置。
2.3 设计思路:信号驱动与分层处理
基于以上理解,我们的设计方案可以概括为“信号驱动,分层响应”:
- 监听信号:Godot的
Window节点提供了一个非常关键的信号:size_changed。当窗口(屏幕)尺寸改变时,这个信号会被触发。这是我们所有适配逻辑的起点。 - 计算新比例:在信号回调函数中,获取新的窗口尺寸,计算当前的宽高比。
- 调整游戏世界(视口):根据预设的适配模式(如
expand)和设计分辨率,重新计算并设置根视口的缩放和偏移。这一步确保游戏场景本身能正确适配。 - 更新UI布局:遍历或通知所有关键的UI控件,根据新的屏幕尺寸和安全区域,重新计算它们的位置和大小。这里要充分利用
Control节点的锚点、边距和容器(Container)功能。 - 可选:调整游戏逻辑:对于一些依赖屏幕坐标的游戏逻辑(如触摸输入坐标转换、敌人生成位置),也需要根据新的视口变换进行更新。
这个流程的核心是将适配逻辑集中管理,通常我会创建一个名为ScreenManager或UIManager的全局单例(Autoload)节点来负责,而不是把代码分散在各个UI场景里。
3. 实战配置:项目设置与场景准备
理论清楚了,我们开始动手配置。一个良好的起点能避免后续很多麻烦。
3.1 关键的项目设置(Project Settings)
打开项目 -> 项目设置,以下几个设置至关重要:
- 显示 -> 窗口:
- 视图 -> 宽度 / 高度:这里设置的是你的设计分辨率,即你制作UI和摆放场景元素时参考的基准尺寸。例如,对于横屏游戏,可以设为1920x1080;对于竖屏游戏,可以设为1080x1920。这个数字不是固定的,但它决定了你所有UI控件坐标的“标尺”。
- 视图 -> 拉伸 -> 模式:这是最重要的设置之一。推荐选择
canvas_items模式。这个模式专为2D和UI设计,它能够正确地根据你的设置缩放所有CanvasItem(包括Sprite和Control节点)。 - 视图 -> 拉伸 -> 纵横比:选择
expand。这就是我们之前讨论的“扩展”策略。它允许输出画面在保持设计宽高比的前提下扩展,以填满屏幕,可能会裁剪掉设计分辨率之外的部分。另一种常用的是keep,它会保持设计分辨率比例并在四周留黑边。 - 视图 -> 拉伸 -> 缩放:设置为
2d。这确保了2D元素在缩放时保持像素清晰度,对于像素风游戏尤其重要。
注意:很多新手会忽略这里的设置,直接去代码里硬算缩放,结果事倍功半。Godot的拉伸系统非常强大,正确配置后,引擎底层会自动处理大量的基础缩放工作,我们只需要关注布局逻辑即可。
3.2 构建一个自适应UI场景
我们来创建一个简单的测试主场景,包含需要自适应的UI元素。
- 创建一个新的
Node2D场景,命名为Main,保存为main.tscn。 - 在
Main节点下添加一个ColorRect作为背景,将其锚点设置为“全范围”(点击布局按钮选择“全矩形”),这样它会自动填满整个父节点(即视口)。 - 添加一个
Control节点作为UI根节点,命名为UI。同样,将其锚点设置为“全范围”。 - 在
UI节点下,添加几个测试控件:- 一个标题Label:将其水平锚点设置为
0.5(居中),垂直锚点设置为顶部(如0.1)。这意味着它的水平中心始终在屏幕宽度的50%位置,顶部距离屏幕顶部的距离占屏幕高度的10%。 - 一个底部按钮Container:添加一个
HBoxContainer,在里面放几个Button。将这个HBoxContainer的锚点底部对齐到屏幕底部,并设置合适的底部边距。 - 一个安全区域参考框:添加一个
Panel节点,我们稍后用代码控制它来显示屏幕的安全区域(避开刘海屏、圆角等)。
- 一个标题Label:将其水平锚点设置为
关键技巧在于:尽可能使用锚点和容器,而不是硬编码的像素位置。一个将锚点右部对齐到父节点右侧,并设置了右边界距离为20像素的按钮,在任何屏幕宽度下,它距离屏幕右边界的距离都是20像素。
4. 核心代码实现:ScreenManager单例
现在进入核心环节,创建我们的屏幕管理单例。这是实现自动适配的“大脑”。
4.1 创建与初始化单例
- 创建一个新的GDScript文件,命名为
screen_manager.gd。 - 在
项目 -> 项目设置 -> Autoload中,添加这个脚本,名称设为ScreenManager,确保“全局变量”复选框被勾选。这样它会在游戏启动时自动加载,并可以在任何脚本中通过ScreenManager访问。
# screen_manager.gd extends Node # 设计分辨率,应与项目设置中的一致 var design_resolution := Vector2(1920, 1080) # 当前屏幕方向 var current_orientation := ORIENTATION.LANDSCAPE enum ORIENTATION { LANDSCAPE, PORTRAIT } func _ready(): # 监听窗口尺寸变化信号 get_tree().root.size_changed.connect(_on_window_size_changed) # 初始化一次适配 _on_window_size_changed()4.2 响应窗口尺寸变化
_on_window_size_changed函数是整个流程的枢纽。在这里,我们获取当前窗口的实际尺寸,判断方向,并驱动后续的适配逻辑。
func _on_window_size_changed(): var window_size: Vector2 = DisplayServer.window_get_size() print("窗口尺寸变化: ", window_size) # 判断当前是横屏还是竖屏 var new_orientation = ORIENTATION.LANDSCAPE if window_size.x > window_size.y else ORIENTATION.PORTRAIT # 如果方向确实改变了,可以触发特定逻辑(如重新布局某些全屏UI) if new_orientation != current_orientation: print("屏幕方向改变: ", current_orientation, " -> ", new_orientation) current_orientation = new_orientation orientation_changed.emit(current_orientation) # 发射自定义信号,供其他节点订阅 # 执行适配更新 _update_viewport_scale(window_size) _update_ui_layout(window_size) # 自定义信号,用于通知其他节点屏幕方向已变 signal orientation_changed(orientation)4.3 更新视口缩放与变换
这一步确保我们的游戏世界(2D场景)能正确适配。我们根据当前窗口尺寸和设计分辨率,计算出一个缩放比例和偏移量。
func _update_viewport_scale(window_size: Vector2): var root_viewport: Viewport = get_tree().root # 注意:在`canvas_items`拉伸模式下,我们通常不直接缩放根视口, # 而是依靠项目设置中的拉伸参数。但有时我们需要知道这个缩放系数来调整其他逻辑。 # 计算基于设计分辨率和当前窗口尺寸的理想缩放比例 var scale_x: float = window_size.x / design_resolution.x var scale_y: float = window_size.y / design_resolution.y # 采用“扩展”策略,取较小的缩放比,保证内容完全显示(可能留黑边) # 或者取较大的缩放比,保证填满屏幕(可能裁剪)。这里演示填满屏幕。 var used_scale: float = max(scale_x, scale_y) # 计算视口实际渲染区域在窗口中的偏移(用于居中) var viewport_effective_size = design_resolution * used_scale var offset = (window_size - viewport_effective_size) * 0.5 print("视口计算 - 缩放: ", used_scale, ", 偏移: ", offset) # 这个缩放和偏移信息非常重要!对于需要精确屏幕坐标转换的逻辑(如触摸输入), # 必须用它们进行转换。例如,将触摸位置转换为游戏世界坐标: # var world_pos = (touch_screen_pos - offset) / used_scale实操心得:对于纯UI应用或某些2D游戏,你可能不需要手动计算这些。Godot的拉伸系统在
canvas_items+expand模式下已经做得很好。但当你需要知道“当前屏幕上的一个像素对应设计分辨率下的多少个单位”时,这个计算就必不可少。例如,你想让一个敌人始终在屏幕右侧外10%的位置生成,就需要这个比例。
4.4 驱动UI动态布局
这是UI适配的核心。我们通过一个信号或直接调用,通知UI根节点去更新它所有子控件的位置。
func _update_ui_layout(window_size: Vector2): # 方法1:直接获取UI根节点并调用其更新函数(假设UI根节点有`update_layout`方法) var ui_root = get_node("/root/Main/UI") # 根据你的场景结构调整路径 if ui_root && ui_root.has_method("update_layout"): ui_root.update_layout(window_size, current_orientation) # 方法2:更优雅的方式,使用自定义信号(推荐) # 我们在ScreenManager中定义一个`layout_update_requested`信号 # UI根节点在初始化时连接这个信号 # 这里直接发射信号 layout_update_requested.emit(window_size, current_orientation) # 自定义信号,请求UI更新布局 signal layout_update_requested(window_size, orientation)然后,在你的UI根节点脚本(例如ui.gd)中:
# ui.gd (附加在UI根Control节点上) extends Control @onready var title_label: Label = $TitleLabel @onready var bottom_panel: Panel = $BottomPanel @onready var safe_area_marker: Panel = $SafeAreaMarker func _ready(): # 连接ScreenManager的布局更新请求信号 ScreenManager.layout_update_requested.connect(_on_layout_update_requested) func _on_layout_update_requested(window_size: Vector2, orientation): print("UI收到布局更新请求,方向:", orientation) # 1. 更新安全区域显示(移动端重要!) _update_safe_area(window_size) # 2. 根据方向,可能需要对特定控件进行显式调整 if orientation == ScreenManager.ORIENTATION.PORTRAIT: # 竖屏时,标题字体可以调小一点,或者调整位置 title_label.add_theme_font_size_override("font_size", 40) # 底部面板可能需要改变排列方式 bottom_panel.layout = VBoxContainer.Layout.VERTICAL else: title_label.add_theme_font_size_override("font_size", 60) bottom_panel.layout = HBoxContainer.Layout.HORIZONTAL # 3. 强制刷新容器布局 # 对于使用了Container的UI,有时需要调用`queue_sort`或`queue_redraw` bottom_panel.queue_redraw() func _update_safe_area(window_size: Vector2): # 获取设备的安全区域(避开刘海、状态栏、手势区) # 注意:Godot 4.1中获取安全区域的API可能因平台而异,这里是一个通用思路 var safe_area: Rect2 = DisplayServer.get_display_safe_area() # 这是一个实验性API,使用时需注意版本 # 如果没有直接API,可以基于平台和屏幕比例估算,或使用插件 # 将安全区域映射到当前UI的坐标空间(考虑缩放和偏移) # 这里需要用到ScreenManager中计算的`used_scale`和`offset`(需要将其设为公开变量或通过方法获取) # var scaled_safe_pos = (safe_area.position - offset) / used_scale # var scaled_safe_size = safe_area.size / used_scale # 设置安全区域参考框的位置和大小 # safe_area_marker.position = scaled_safe_pos # safe_area_marker.size = scaled_safe_size pass5. 进阶技巧与常见问题排查
掌握了基础流程后,我们来看看一些能提升体验和鲁棒性的进阶技巧,以及如何解决那些让人头疼的常见问题。
5.1 处理输入坐标转换
横竖屏切换后,最大的陷阱之一是输入坐标(特别是触摸)错乱。你点击屏幕上一个按钮的位置,事件却触发在了别处。
原因:触摸事件返回的坐标是相对于窗口的绝对坐标。但你的UI和游戏世界是在经过缩放和偏移的视口中绘制的。你需要将触摸坐标转换到设计分辨率坐标系下。
解决方案:在ScreenManager中提供一个静态的坐标转换方法。
# 在screen_manager.gd中添加 # 假设我们已经将计算好的used_scale和offset存储为成员变量 var _current_scale: float = 1.0 var _current_offset: Vector2 = Vector2.ZERO func _update_viewport_scale(window_size: Vector2): # ... 之前的计算逻辑 ... _current_scale = used_scale _current_offset = offset # 提供一个静态方法,将屏幕坐标转换为设计分辨率坐标 static func screen_to_design(screen_pos: Vector2) -> Vector2: var instance = _get_instance() if instance: return (screen_pos - instance._current_offset) / instance._current_scale return screen_pos # 回退 static func _get_instance(): return Engine.get_main_loop().root.find_child("ScreenManager", true, false)在接收输入的脚本中(如一个按钮或自定义的触摸处理逻辑):
func _input(event): if event is InputEventScreenTouch and event.pressed: var design_coord = ScreenManager.screen_to_design(event.position) # 现在,design_coord就可以和你UI控件在设计分辨率下的Rect进行比较了 if my_button_rect.has_point(design_coord): print("按钮被点击!")5.2 不同设备与分辨率的测试策略
你不可能拥有所有型号的手机,但测试必须覆盖主流情况。
- Godot编辑器内模拟:在编辑器运行游戏时,你可以直接拖动游戏窗口的边角来改变其大小,模拟不同屏幕比例。这是最快速的测试方法。
- 使用设备模拟器:Android Studio和Xcode提供的设备模拟器非常强大,可以模拟各种分辨率、屏幕密度和带有刘海屏的设备。
- 导出到真机测试:这是必不可少的环节。准备几台具有代表性的设备:
- 一款老款、分辨率较低的手机。
- 一款新款、全面屏、高分辨率的手机。
- 一款平板电脑(比例差异大)。
- 创建测试场景:在游戏里做一个隐藏的调试界面,可以手动触发横竖屏切换,并实时显示当前窗口尺寸、缩放比例、安全区域等信息。
5.3 常见问题速查与解决方案
下表整理了一些典型问题及其排查思路:
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 切换屏幕方向后,UI位置错乱或消失。 | 1. UI控件的锚点未正确设置。 2. 布局更新函数未被调用或调用时机不对。 3. 容器(如 HBoxContainer)未正确排序。 | 1. 检查关键UI节点的锚点属性,确保它们相对于父节点的定位是正确的。 2. 在 ScreenManager的_on_window_size_changed函数中加打印,确认信号触发。3. 尝试在UI根节点调用 queue_sort()或queue_redraw()。 |
| 游戏场景(精灵、瓦片地图)被拉伸变形。 | 项目设置中的“拉伸 -> 模式”或“纵横比”设置错误。 | 1. 确认项目设置 -> 显示 -> 窗口 -> 拉伸中,模式为canvas_items,纵横比为expand或keep。2. 检查场景中根节点的缩放是否被意外修改。 |
| 触摸点击位置不准。 | 未进行屏幕坐标到设计分辨率坐标的转换。 | 1. 使用ScreenManager.screen_to_design()方法转换触摸坐标。2. 确认用于碰撞检测的Rect使用的是设计分辨率坐标系下的值。 |
| 在特定设备(如带刘海屏的手机)上,UI内容被遮挡。 | 未考虑安全区域(Safe Area)。 | 1. 查阅Godot文档,使用DisplayServer.get_display_safe_area()(或平台特定方法)获取安全区域。2. 在UI布局时,将关键控件(如返回按钮、血量条)放置在安全区域内。 |
| 屏幕旋转时,游戏有短暂黑屏或闪烁。 | 1. 场景重新布局计算量过大。 2. 纹理等资源在旋转时重新加载。 | 1. 优化_update_ui_layout函数,避免每帧进行大量计算或节点查找。2. 确保使用的纹理资源在导入设置中勾选了 VRAM Compressed等选项,并已正确加载。 |
| 导出到移动设备后,屏幕方向被锁定。 | 未在导出预设中配置屏幕方向。 | 1. 对于Android:在导出 -> Android -> 屏幕中,勾选允许的方向(如landscape和portrait)。2. 对于iOS:需要在 导出 -> iOS的plist设置中添加对应的方向键值。 |
5.4 性能优化与小技巧
- 避免每帧更新:所有布局更新逻辑都应只在
size_changed信号触发时运行。不要在_process或_physics_process中持续检查屏幕尺寸。 - 使用
queue_redraw():对于复杂的自定义绘制UI,在布局更新后调用queue_redraw(),而不是立即draw,让引擎在下一帧统一处理。 - 预设不同方向的UI布局:对于横竖屏布局差异巨大的情况,可以考虑准备两套不同的UI场景(
ui_landscape.tscn和ui_portrait.tscn),在方向切换时动态实例化和切换,而不是用一套UI通过代码强行变换。 - 动画过渡:如果UI元素在旋转时位置变化很大,可以考虑使用
Tween节点为其位置和大小变化添加一个短暂的补间动画,提升视觉体验。
6. 完整代码整合与示例
最后,我将提供一个整合后的、更健壮的ScreenManager示例代码,以及一个简单的UI场景脚本,你可以直接复制到项目中作为起点。
screen_manager.gd (完整版)
extends Node class_name ScreenManager # 设计分辨率 @export var design_resolution: Vector2 = Vector2(1920, 1080) # 当前方向 var current_orientation := ORIENTATION.LANDSCAPE # 当前缩放与偏移(用于坐标转换) var _current_scale: float = 1.0 var _current_offset: Vector2 = Vector2.ZERO enum ORIENTATION { LANDSCAPE, PORTRAIT } # 自定义信号 signal orientation_changed(orientation) signal layout_update_requested(window_size, orientation, scale, offset) func _ready(): # 确保单例在场景树中 pause_mode = Node.PAUSE_MODE_PROCESS # 连接信号 var root_window = get_tree().root if root_window.size_changed.connect(_on_window_size_changed) != OK: push_error("ScreenManager: 无法连接 size_changed 信号") # 初始适配 call_deferred("_on_window_size_changed") func _on_window_size_changed(): var window_size: Vector2 = DisplayServer.window_get_size() if window_size.x <= 0 || window_size.y <= 0: return # 无效尺寸 # 判断方向 var new_orientation = ORIENTATION.LANDSCAPE if window_size.x > window_size.y else ORIENTATION.PORTRAIT # 计算视口缩放与偏移(基于expand策略,填满屏幕) var scale_x: float = window_size.x / design_resolution.x var scale_y: float = window_size.y / design_resolution.y var used_scale: float = max(scale_x, scale_y) var viewport_effective_size = design_resolution * used_scale var offset = (window_size - viewport_effective_size) * 0.5 # 更新内部状态 _current_scale = used_scale _current_offset = offset # 如果方向改变,发出信号 if new_orientation != current_orientation: current_orientation = new_orientation orientation_changed.emit(current_orientation) print("ScreenManager: 方向切换至 ", "横屏" if current_orientation == ORIENTATION.LANDSCAPE else "竖屏") # 请求UI更新布局 layout_update_requested.emit(window_size, current_orientation, used_scale, offset) # 公共方法:坐标转换 func screen_to_design(screen_pos: Vector2) -> Vector2: return (screen_pos - _current_offset) / _current_scale func design_to_screen(design_pos: Vector2) -> Vector2: return (design_pos * _current_scale) + _current_offset # 静态访问器(方便全局调用) static func get_screen_to_design(screen_pos: Vector2) -> Vector2: var instance = _get_instance() return instance.screen_to_design(screen_pos) if instance else screen_pos static func _get_instance(): return Engine.get_main_loop().root.find_child("ScreenManager", true, false) as ScreenManagermain_ui.gd (附加在UI根节点)
extends Control @onready var title_label: Label = $TitleLabel @onready var debug_info_label: Label = $DebugInfoLabel func _ready(): # 连接ScreenManager信号 if ScreenManager: ScreenManager.layout_update_requested.connect(_on_layout_update_requested) else: push_warning("MainUI: 未找到ScreenManager单例,UI适配可能失效。") func _on_layout_update_requested(window_size: Vector2, orientation, scale, offset): # 更新调试信息 debug_info_label.text = "窗口: %s\n方向: %s\n缩放: %.2f\n偏移: %s" % [ window_size, "横屏" if orientation == ScreenManager.ORIENTATION.LANDSCAPE else "竖屏", scale, offset ] # 根据方向微调UI _adjust_ui_for_orientation(orientation) # 通知所有子控件(如果有需要的话) _propagate_layout_update() func _adjust_ui_for_orientation(orientation): match orientation: ScreenManager.ORIENTATION.LANDSCAPE: title_label.add_theme_font_size_override("font_size", 64) title_label.position.y = 50 ScreenManager.ORIENTATION.PORTRAIT: title_label.add_theme_font_size_override("font_size", 48) title_label.position.y = 80 func _propagate_layout_update(): # 这里可以遍历所有需要动态布局的子控件,调用它们的更新方法 for child in get_children(): if child.has_method("on_parent_layout_updated"): child.on_parent_layout_updated()把这两个脚本配置好,并正确设置好项目属性后,你的Godot游戏就获得了基础的横竖屏自动适配能力。记住,UI适配是一个细致活,需要针对每个界面元素进行测试和调整。最好的习惯是,在开发初期就使用多种比例的窗口进行测试,尽早发现布局问题,而不是等到开发末期再统一修补。