Godot 4 GDScript 教程 / 27 - 移动端适配
27 - 移动端适配
移动游戏市场巨大,但移动设备在性能、屏幕尺寸和交互方式上与 PC 差异显著。本文将介绍 Godot 4 中移动端开发的核心技术和最佳实践。
触摸输入优化
基础触摸事件
# touch_handler.gd
extends Node2D
var active_touches: Dictionary = {} # finger_index -> Vector2
func _input(event: InputEvent):
if event is InputEventScreenTouch:
if event.pressed:
_on_touch_start(event.index, event.position)
else:
_on_touch_end(event.index, event.position)
elif event is InputEventScreenDrag:
_on_touch_move(event.index, event.position, event.relative)
func _on_touch_start(finger: int, position: Vector2):
active_touches[finger] = position
print("触摸开始: 手指 %d 位置 %s" % [finger, position])
func _on_touch_end(finger: int, position: Vector2):
active_touches.erase(finger)
print("触摸结束: 手指 %d" % finger)
func _on_touch_move(finger: int, position: Vector2, relative: Vector2):
active_touches[finger] = position
# 处理拖拽
手势识别
# gesture_detector.gd
extends Node
signal swipe_detected(direction: Vector2)
signal pinch_detected(scale_factor: float)
signal tap_detected(position: Vector2)
signal long_press_detected(position: Vector2)
@export var swipe_threshold: float = 50.0
@export var long_press_duration: float = 0.5
var touch_start: Dictionary = {} # finger -> {pos, time}
var is_pinching: bool = false
var initial_pinch_distance: float = 0.0
func _input(event: InputEvent):
if event is InputEventScreenTouch:
if event.pressed:
touch_start[event.index] = {
"pos": event.position,
"time": Time.get_ticks_msec()
}
else:
if touch_start.has(event.index):
var start = touch_start[event.index]
var duration = Time.get_ticks_msec() - start.time
var distance = event.position.distance_to(start.pos)
if duration < 200 and distance < 20:
tap_detected.emit(event.position)
elif distance > swipe_threshold and duration < 500:
var direction = (event.position - start.pos).normalized()
swipe_detected.emit(direction)
touch_start.erase(event.index)
elif event is InputEventScreenDrag:
if touch_start.size() == 2 and event.index in touch_start:
if not is_pinching:
var fingers = touch_start.keys()
var pos0 = touch_start[fingers[0]].pos
var pos1 = touch_start[fingers[1]].pos
initial_pinch_distance = pos0.distance_to(pos1)
is_pinching = true
else:
var fingers = touch_start.keys()
var pos0 = touch_start[fingers[0]].pos
var pos1 = touch_start[fingers[1]].pos
var current_distance = pos0.distance_to(pos1)
var scale_factor = current_distance / initial_pinch_distance
pinch_detected.emit(scale_factor)
虚拟摇杆实现
# virtual_joystick.gd
extends Control
signal input_direction(direction: Vector2)
signal input_strength(strength: float)
@export var dead_zone: float = 0.1
@export var clamp_zone: float = 1.0
@export var use_touch_screen_only: bool = true
@onready var base: TextureRect = $Base
@onready var stick: TextureRect = $Stick
var touch_index: int = -1
var current_direction: Vector2 = Vector2.ZERO
var current_strength: float = 0.0
var base_radius: float = 64.0
func _ready():
if base.texture:
base_radius = base.texture.get_width() * 0.4
stick.position = base.position + Vector2(base_radius, base_radius)
func _input(event: InputEvent):
if not visible:
return
if event is InputEventScreenTouch:
if event.pressed and _is_point_inside(event.position):
touch_index = event.index
_update_stick(event.position)
elif not event.pressed and event.index == touch_index:
_reset_stick()
elif event is InputEventScreenDrag and event.index == touch_index:
_update_stick(event.position)
func _is_point_inside(point: Vector2) -> bool:
var center = global_position + Vector2(base_radius, base_radius)
return point.distance_to(center) <= base_radius * 1.5
func _update_stick(touch_pos: Vector2):
var center = global_position + Vector2(base_radius, base_radius)
var delta = touch_pos - center
var distance = delta.length()
if distance > base_radius:
delta = delta.normalized() * base_radius
distance = base_radius
stick.position = Vector2(base_radius, base_radius) + delta
current_strength = clampf(distance / base_radius, 0.0, 1.0)
current_direction = delta.normalized() if distance > base_radius * dead_zone else Vector2.ZERO
if current_strength < dead_zone:
current_direction = Vector2.ZERO
current_strength = 0.0
input_direction.emit(current_direction)
input_strength.emit(current_strength)
func _reset_stick():
touch_index = -1
stick.position = Vector2(base_radius, base_radius)
current_direction = Vector2.ZERO
current_strength = 0.0
input_direction.emit(Vector2.ZERO)
input_strength.emit(0.0)
func get_direction() -> Vector2:
return current_direction
func get_strength() -> float:
return current_strength
使用虚拟摇杆控制角色
# mobile_player.gd
extends CharacterBody3D
@export var speed: float = 5.0
@onready var joystick: Control = %VirtualJoystick
func _physics_process(delta):
var input_dir = joystick.get_direction()
var direction = Vector3(input_dir.x, 0, input_dir.y)
if direction.length() > 0:
velocity.x = direction.x * speed * joystick.get_strength()
velocity.z = direction.z * speed * joystick.get_strength()
else:
velocity.x = move_toward(velocity.x, 0, speed * delta * 5)
velocity.z = move_toward(velocity.z, 0, speed * delta * 5)
move_and_slide()
⚠️ 注意:虚拟摇杆的 dead_zone 很重要,没有死区的话角色会持续微小移动,影响动画播放。
屏幕适配策略
分辨率设置
# screen_adapter.gd
extends Node
const BASE_WIDTH = 1920
const BASE_HEIGHT = 1080
func _ready():
_adapt_to_screen()
func _adapt_to_screen():
var screen_size = DisplayServer.screen_get_size()
var window_size = DisplayServer.window_get_size()
# 设置拉伸模式
get_tree().root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
get_tree().root.content_scale_size = Vector2i(BASE_WIDTH, BASE_HEIGHT)
# 适配策略
var aspect_ratio = float(screen_size.x) / float(screen_size.y)
var base_aspect = float(BASE_WIDTH) / float(BASE_HEIGHT)
if aspect_ratio > base_aspect:
# 屏幕更宽:增加水平视野
get_tree().root.content_scale_mode = Window.CONTENT_SCALE_MODE_VIEWPORT
else:
# 屏幕更窄:增加垂直内容
get_tree().root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS
项目设置推荐
# 在 project.godot 中配置
# display/window/size/viewport_width = 1920
# display/window/size/viewport_height = 1080
# display/window/stretch/mode = "canvas_items"
# display/window/stretch/aspect = "expand"
# display/window/stretch/scale_mode = "canvas_items"
| 拉伸模式 | 说明 | 适用场景 |
|---|---|---|
disabled | 不拉伸 | 固定分辨率 |
canvas_items | 拉伸 UI | 大多数游戏 |
viewport | 拉伸整个视口 | 像素风格游戏 |
移动端 UI 设计
自适应布局
# mobile_ui.gd
extends CanvasLayer
@onready var hud: Control = $HUD
@onready var joystick: Control = $VirtualJoystick
@onready var action_buttons: HBoxContainer = $ActionButtons
func _ready():
get_tree().root.size_changed.connect(_on_screen_changed)
_adapt_layout()
func _on_screen_changed():
_adapt_layout()
func _adapt_layout():
var screen_size = get_viewport().get_visible_rect().size
var is_landscape = screen_size.x > screen_size.y
var is_tablet = _is_tablet()
# 调整 UI 元素大小
var ui_scale = 1.0
if is_tablet:
ui_scale = 1.2
elif screen_size.y < 720:
ui_scale = 0.8
hud.scale = Vector2.ONE * ui_scale
# 调整布局
if is_landscape:
joystick.position = Vector2(100, screen_size.y - 200)
action_buttons.position = Vector2(screen_size.x - 300, screen_size.y - 200)
else:
joystick.position = Vector2(80, screen_size.y - 250)
action_buttons.position = Vector2(screen_size.x - 250, screen_size.y - 250)
# 安全区域适配
_apply_safe_area()
func _apply_safe_area():
var safe_area = DisplayServer.get_display_safe_area()
var viewport_size = get_viewport().get_visible_rect().size
var screen_size = DisplayServer.screen_get_size()
var scale = viewport_size / Vector2(screen_size)
var safe_rect = Rect2(
Vector2(safe_area.position) * scale,
Vector2(safe_area.size) * scale
)
# 设置 UI 安全边距
hud.offset_left = safe_rect.position.x
hud.offset_right = -(viewport_size.x - safe_rect.end.x)
hud.offset_top = safe_rect.position.y
hud.offset_bottom = -(viewport_size.y - safe_rect.end.y)
func _is_tablet() -> bool:
var dpi = DisplayServer.screen_get_dpi()
var size = DisplayServer.screen_get_size()
var diagonal_inches = sqrt(pow(size.x, 2) + pow(size.y, 2)) / dpi
return diagonal_inches > 7.0
💡 移动端 UI 元素的最小可触摸区域应为 48x48 像素(约 9mm),这是 Material Design 的推荐标准。
移动端性能优化
分辨率与帧率
# mobile_performance.gd
extends Node
enum QualityLevel { LOW, MEDIUM, HIGH }
@export var target_fps: int = 60
@export var quality: QualityLevel = QualityLevel.MEDIUM
func _ready():
apply_quality_settings()
func apply_quality_settings():
match quality:
QualityLevel.LOW:
_set_low_quality()
QualityLevel.MEDIUM:
_set_medium_quality()
QualityLevel.HIGH:
_set_high_quality()
func _set_low_quality():
# 降低渲染分辨率
get_viewport().scaling_3d_scale = 0.5
# 关闭阴影
get_viewport().positional_shadow_atlas_size = 0
# 关闭环境遮蔽
ProjectSettings.set_setting("rendering/environment/ssao/quality", 0)
# 限制帧率
Engine.max_fps = 30
func _set_medium_quality():
get_viewport().scaling_3d_scale = 0.75
get_viewport().positional_shadow_atlas_size = 1024
Engine.max_fps = 60
func _set_high_quality():
get_viewport().scaling_3d_scale = 1.0
get_viewport().positional_shadow_atlas_size = 2048
Engine.max_fps = 60
# 动态分辨率调整
func _process(_delta):
var fps = Engine.get_frames_per_second()
if fps < target_fps * 0.8:
# 性能不足,降低分辨率
var scale = get_viewport().scaling_3d_scale
get_viewport().scaling_3d_scale = maxf(scale - 0.05, 0.5)
elif fps > target_fps * 0.95 and quality != QualityLevel.LOW:
# 性能有余裕,提升分辨率
var scale = get_viewport().scaling_3d_scale
get_viewport().scaling_3d_scale = minf(scale + 0.05, 1.0)
移动端导出配置
Android 导出配置
# android_export_settings.gd
# 需要在项目设置中配置:
#
# android/modules:
# - 清除不需要的模块减小 APK 体积
#
# gradle_build/export_format = 1 (APK)
# gradle_build/min_sdk = "24"
# gradle_build/target_sdk = "34"
#
# screen/immersive_mode = true (全屏沉浸模式)
# screen/support_small = true
# screen/support_normal = true
# screen/support_large = true
# screen/support_xlarge = true
| 配置项 | 推荐值 | 说明 |
|---|---|---|
min_sdk | 24 (Android 7.0) | 最低支持版本 |
target_sdk | 34 | 目标 API 版本 |
export_format | AAB (Play Store) | 应用包格式 |
architectures | arm64, armv7 | 支持的 CPU 架构 |
xr_mode | Disabled | 除非做 VR 游戏 |
通知推送
# push_notification.gd
extends Node
func _ready():
if OS.get_name() == "Android":
_setup_android_notifications()
func _setup_android_notifications():
# 需要 android-notification 插件
var plugin = Engine.get_singleton("GodotNotifications")
if plugin:
plugin.notification_received.connect(_on_notification_received)
func schedule_local_notification(title: String, body: String, delay_seconds: int):
if OS.get_name() == "Android":
var plugin = Engine.get_singleton("GodotNotifications")
if plugin:
plugin.schedule_notification(title, body, delay_seconds)
func _on_notification_received(data: Dictionary):
print("收到通知: ", data)
内购集成
# in_app_purchase.gd
extends Node
signal purchase_success(product_id: String)
signal purchase_failed(product_id: String, error: String)
signal purchase_restored(product_id: String)
var payment: Object # GodotGoogleBilling 或 IAP 插件接口
func _ready():
if Engine.has_singleton("GodotGoogleBilling"):
_setup_google_play_billing()
func _setup_google_play_billing():
payment = Engine.get_singleton("GodotGoogleBilling")
payment.connect("connected", _on_connected)
payment.connect("purchases_updated", _on_purchases_updated)
payment.startConnection()
func _on_connected():
print("已连接到 Google Play 计费")
# 查询商品详情
payment.querySkuDetails(["premium_upgrade", "gold_pack_100"], "inapp")
func purchase(product_id: String):
if payment:
payment.purchase(product_id)
func _on_purchases_updated(response_code: int, purchases: Array):
if response_code == 0: # OK
for purchase_data in purchases:
var product_id = purchase_data.sku
# 验证购买(服务端验证更安全)
_grant_item(product_id)
# 确认购买
payment.acknowledgePurchase(product_id)
purchase_success.emit(product_id)
else:
purchase_failed.emit("", "购买失败: %d" % response_code)
func _grant_item(product_id: String):
match product_id:
"premium_upgrade":
GameManager.set_premium(true)
"gold_pack_100":
GameManager.add_gold(100)
移动端测试策略
| 测试类型 | 工具 | 说明 |
|---|---|---|
| 真机测试 | USB/WiFi 调试 | 必须在真机上测试触摸和性能 |
| 模拟器测试 | Android Studio AVD | 快速 UI 测试 |
| 性能测试 | Godot Profiler | 分析 FPS、内存、DrawCall |
| 兼容性测试 | Firebase Test Lab | 跨设备测试 |
| 网络测试 | 限速工具 | 模拟弱网环境 |
# mobile_debug_overlay.gd
extends CanvasLayer
@onready var fps_label: Label = %FPS
@onready var memory_label: Label = %Memory
@onready var draw_calls_label: Label = %DrawCalls
@onready var touch_label: Label = %Touches
func _ready():
visible = OS.is_debug_build()
func _process(_delta):
if not visible:
return
fps_label.text = "FPS: %d" % Engine.get_frames_per_second()
memory_label.text = "MEM: %.0f MB" % (Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576)
draw_calls_label.text = "DC: %d" % Performance.get_monitor(Performance.RENDER_DRAW_CALLS_IN_FRAME)
广告集成
# ad_manager.gd
extends Node
signal ad_loaded(ad_type: String)
signal ad_failed(ad_type: String, error: String)
signal ad_rewarded(reward_type: String, amount: int)
enum AdType { BANNER, INTERSTITIAL, REWARDED }
var ad_plugin: Object
var is_initialized: bool = false
func _ready():
_init_ads()
func _init_ads():
if Engine.has_singleton("GodotAdMob"):
ad_plugin = Engine.get_singleton("GodotAdMob")
ad_plugin.initialize(true) # 测试模式
is_initialized = true
func show_banner():
if is_initialized:
ad_plugin.show_banner()
func hide_banner():
if is_initialized:
ad_plugin.hide_banner()
func show_interstitial():
if is_initialized:
ad_plugin.show_interstitial()
func show_rewarded(callback: Callable):
if is_initialized:
ad_plugin.show_rewarded_video()
# 奖励回调
ad_rewarded.connect(
func(type, amount): callback.call(type, amount),
CONNECT_ONE_SHOT
)
⚠️ 注意:广告集成通常需要第三方插件(如 Godot AdMob)。发布前务必切换到正式广告 ID,测试 ID 不产生收益。