强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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_sdk24 (Android 7.0)最低支持版本
target_sdk34目标 API 版本
export_formatAAB (Play Store)应用包格式
architecturesarm64, armv7支持的 CPU 架构
xr_modeDisabled除非做 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 不产生收益。


💡 扩展阅读