godot-adapt-desktop-to-mobile

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Adapt: Desktop to Mobile

适配:从桌面到移动

Expert guidance for porting desktop games to mobile platforms.
将桌面游戏移植到移动平台的专业指南。

NEVER Do

切勿执行的操作

  • NEVER use mouse position directly — Touch has no "hover" state. Replace mouse_motion with screen_drag and check InputEventScreenTouch.pressed.
  • NEVER keep small UI elements — Apple HIG requires 44pt minimum touch targets. Android Material: 48dp. Scale up buttons 2-3x.
  • NEVER forget finger occlusion — User's finger blocks 50-100px radius. Position critical info ABOVE touch controls, not below.
  • NEVER run at full performance when backgrounded — Mobile OSs kill apps that drain battery in background. Pause physics, reduce FPS to 1-5 when app loses focus.
  • NEVER use desktop-only features — Mouse hover, right-click, keyboard shortcuts, scroll wheel don't exist on mobile. Provide touch alternatives.

  • 切勿直接使用鼠标位置 — 触摸操作没有“悬停”状态。用screen_drag替代mouse_motion,并检查InputEventScreenTouch.pressed状态。
  • 切勿保留过小的UI元素 — Apple HIG要求最小触摸目标为44pt,Android Material设计规范为48dp。将按钮放大2-3倍。
  • 切勿忽略手指遮挡问题 — 用户手指会遮挡50-100px范围的区域。将关键信息放置在触摸控件的上方,而非下方。
  • 应用后台运行时切勿保持满负荷性能 — 移动操作系统会终止后台耗电严重的应用。当应用失去焦点时,暂停物理运算,将FPS降至1-5。
  • 切勿使用仅桌面端支持的功能 — 鼠标悬停、右键点击、键盘快捷键、滚轮在移动平台均不存在。需提供触摸替代方案。

Available Scripts

可用脚本

MANDATORY: Read the appropriate script before implementing the corresponding pattern.
强制要求:在实现对应方案前,请先阅读相应脚本。

mobile_ui_adapter.gd

mobile_ui_adapter.gd

Automatic mobile overrides: scales buttons for touch, applies safe area margins, disables heavy effects (SSAO, SDFGI) for battery/performance.
自动移动平台适配:针对触摸操作缩放按钮、应用安全区域边距、为节省电量和提升性能禁用重型特效(SSAO、SDFGI)。

virtual_joystick.gd

virtual_joystick.gd

Production-ready virtual joystick with multi-touch support, deadzone handling, and visual feedback. Outputs normalized Vector2 direction.

可用于生产环境的虚拟摇杆,支持多点触摸、死区处理和视觉反馈。输出归一化的Vector2方向值。

Touch Control Schemes

触摸控制方案

Decision Matrix

决策矩阵

GenreRecommended ControlExample
PlatformerVirtual joystick (left) + jump button (right)Super Mario Run
Top-down shooterDual-stick (move left, aim right)Brawl Stars
Turn-basedDirect tap on units/tilesInto the Breach
PuzzleTap, swipe, pinch gesturesCandy Crush
Card gameDrag-and-dropHearthstone
RacingTilt steering or tap left/rightAsphalt 9
游戏类型推荐控制方式示例
平台跳跃类左侧虚拟摇杆 + 右侧跳跃按钮《超级马里奥酷跑》
俯视角射击类双摇杆(左摇杆移动,右摇杆瞄准)《荒野乱斗》
回合制策略类直接点击单位/格子《陷阵之志》
益智解谜类点击、滑动、捏合手势《糖果传奇》
卡牌类拖拽操作《炉石传说》
赛车类重力感应或点击左右《狂野飙车9》

Virtual Joystick

虚拟摇杆

gdscript
undefined
gdscript
undefined

virtual_joystick.gd

virtual_joystick.gd

extends Control
signal direction_changed(direction: Vector2)
@export var dead_zone: float = 0.2 @export var max_distance: float = 100.0
var stick_center: Vector2 var is_pressed: bool = false var touch_index: int = -1
@onready var base: Sprite2D = $Base @onready var knob: Sprite2D = $Knob
func _ready() -> void: stick_center = base.position
func _input(event: InputEvent) -> void: if event is InputEventScreenTouch: if event.pressed and is_point_inside(event.position): is_pressed = true touch_index = event.index elif not event.pressed and event.index == touch_index: is_pressed = false reset_knob()
elif event is InputEventScreenDrag and event.index == touch_index:
    update_knob(event.position)
func is_point_inside(point: Vector2) -> bool: return base.get_rect().has_point(base.to_local(point))
func update_knob(touch_pos: Vector2) -> void: var local_pos := to_local(touch_pos) var offset := local_pos - stick_center
# Clamp to max distance
if offset.length() > max_distance:
    offset = offset.normalized() * max_distance

knob.position = stick_center + offset

# Calculate direction (-1 to 1)
var direction := offset / max_distance
if direction.length() < dead_zone:
    direction = Vector2.ZERO

direction_changed.emit(direction)
func reset_knob() -> void: knob.position = stick_center direction_changed.emit(Vector2.ZERO)
undefined
extends Control
signal direction_changed(direction: Vector2)
@export var dead_zone: float = 0.2 @export var max_distance: float = 100.0
var stick_center: Vector2 var is_pressed: bool = false var touch_index: int = -1
@onready var base: Sprite2D = $Base @onready var knob: Sprite2D = $Knob
func _ready() -> void: stick_center = base.position
func _input(event: InputEvent) -> void: if event is InputEventScreenTouch: if event.pressed and is_point_inside(event.position): is_pressed = true touch_index = event.index elif not event.pressed and event.index == touch_index: is_pressed = false reset_knob()
elif event is InputEventScreenDrag and event.index == touch_index:
    update_knob(event.position)
func is_point_inside(point: Vector2) -> bool: return base.get_rect().has_point(base.to_local(point))
func update_knob(touch_pos: Vector2) -> void: var local_pos := to_local(touch_pos) var offset := local_pos - stick_center
# Clamp to max distance
if offset.length() > max_distance:
    offset = offset.normalized() * max_distance

knob.position = stick_center + offset

# Calculate direction (-1 to 1)
var direction := offset / max_distance
if direction.length() < dead_zone:
    direction = Vector2.ZERO

direction_changed.emit(direction)
func reset_knob() -> void: knob.position = stick_center direction_changed.emit(Vector2.ZERO)
undefined

Gesture Detection

手势检测

gdscript
undefined
gdscript
undefined

gesture_detector.gd

gesture_detector.gd

extends Node
signal swipe_detected(direction: Vector2) # Normalized signal pinch_detected(scale: float) # > 1.0 = zoom in signal tap_detected(position: Vector2)
const SWIPE_THRESHOLD := 100.0 # Pixels const TAP_MAX_DISTANCE := 20.0 const TAP_MAX_DURATION := 0.3 # Seconds
var touch_start: Dictionary = {} # index → {position: Vector2, time: float} var pinch_start_distance: float = 0.0
func _input(event: InputEvent) -> void: if event is InputEventScreenTouch: if event.pressed: touch_start[event.index] = { "position": event.position, "time": Time.get_ticks_msec() * 0.001 } else: _handle_release(event)
elif event is InputEventScreenDrag:
    _handle_drag(event)
func _handle_release(event: InputEventScreenTouch) -> void: if event.index not in touch_start: return
var start_data = touch_start[event.index]
var distance := event.position.distance_to(start_data.position)
var duration := (Time.get_ticks_msec() * 0.001) - start_data.time

# Tap detection
if distance < TAP_MAX_DISTANCE and duration < TAP_MAX_DURATION:
    tap_detected.emit(event.position)

# Swipe detection
elif distance > SWIPE_THRESHOLD:
    var direction := (event.position - start_data.position).normalized()
    swipe_detected.emit(direction)

touch_start.erase(event.index)
func _handle_drag(event: InputEventScreenDrag) -> void: # Pinch detection (requires 2 touches) if touch_start.size() == 2: var positions := [] for idx in touch_start.keys(): if idx == event.index: positions.append(event.position) else: positions.append(touch_start[idx].position)
    var current_distance := positions[0].distance_to(positions[1])
    
    if pinch_start_distance == 0.0:
        pinch_start_distance = current_distance
    else:
        var scale := current_distance / pinch_start_distance
        pinch_detected.emit(scale)
        pinch_start_distance = current_distance

---
extends Node
signal swipe_detected(direction: Vector2) # Normalized signal pinch_detected(scale: float) # > 1.0 = zoom in signal tap_detected(position: Vector2)
const SWIPE_THRESHOLD := 100.0 # Pixels const TAP_MAX_DISTANCE := 20.0 const TAP_MAX_DURATION := 0.3 # Seconds
var touch_start: Dictionary = {} # index → {position: Vector2, time: float} var pinch_start_distance: float = 0.0
func _input(event: InputEvent) -> void: if event is InputEventScreenTouch: if event.pressed: touch_start[event.index] = { "position": event.position, "time": Time.get_ticks_msec() * 0.001 } else: _handle_release(event)
elif event is InputEventScreenDrag:
    _handle_drag(event)
func _handle_release(event: InputEventScreenTouch) -> void: if event.index not in touch_start: return
var start_data = touch_start[event.index]
var distance := event.position.distance_to(start_data.position)
var duration := (Time.get_ticks_msec() * 0.001) - start_data.time

# Tap detection
if distance < TAP_MAX_DISTANCE and duration < TAP_MAX_DURATION:
    tap_detected.emit(event.position)

# Swipe detection
elif distance > SWIPE_THRESHOLD:
    var direction := (event.position - start_data.position).normalized()
    swipe_detected.emit(direction)

touch_start.erase(event.index)
func _handle_drag(event: InputEventScreenDrag) -> void: # Pinch detection (requires 2 touches) if touch_start.size() == 2: var positions := [] for idx in touch_start.keys(): if idx == event.index: positions.append(event.position) else: positions.append(touch_start[idx].position)
    var current_distance := positions[0].distance_to(positions[1])
    
    if pinch_start_distance == 0.0:
        pinch_start_distance = current_distance
    else:
        var scale := current_distance / pinch_start_distance
        pinch_detected.emit(scale)
        pinch_start_distance = current_distance

---

UI Scaling

UI缩放

Responsive Layout

响应式布局

gdscript
undefined
gdscript
undefined

Adjust UI for different screen sizes

Adjust UI for different screen sizes

extends Control
func _ready() -> void: get_viewport().size_changed.connect(_on_viewport_resized) _on_viewport_resized()
func _on_viewport_resized() -> void: var viewport_size := get_viewport_rect().size var aspect_ratio := viewport_size.x / viewport_size.y
# Adjust for different aspect ratios
if aspect_ratio > 2.0:  # Ultra-wide (tablets in landscape)
    scale_ui_for_tablet()
elif aspect_ratio < 0.6:  # Tall (phones in portrait)
    scale_ui_for_phone()

# Adjust touch button sizes
for button in get_tree().get_nodes_in_group("touch_buttons"):
    var min_size := 88  # 44pt * 2 for Retina
    button.custom_minimum_size = Vector2(min_size, min_size)
func scale_ui_for_tablet() -> void: # Spread UI to edges, use horizontal space $LeftControls.position.x = 100 $RightControls.position.x = get_viewport_rect().size.x - 100
func scale_ui_for_phone() -> void: # Keep UI at bottom, vertically compact $LeftControls.position.y = get_viewport_rect().size.y - 200 $RightControls.position.y = get_viewport_rect().size.y - 200

---
extends Control
func _ready() -> void: get_viewport().size_changed.connect(_on_viewport_resized) _on_viewport_resized()
func _on_viewport_resized() -> void: var viewport_size := get_viewport_rect().size var aspect_ratio := viewport_size.x / viewport_size.y
# Adjust for different aspect ratios
if aspect_ratio > 2.0:  # Ultra-wide (tablets in landscape)
    scale_ui_for_tablet()
elif aspect_ratio < 0.6:  # Tall (phones in portrait)
    scale_ui_for_phone()

# Adjust touch button sizes
for button in get_tree().get_nodes_in_group("touch_buttons"):
    var min_size := 88  # 44pt * 2 for Retina
    button.custom_minimum_size = Vector2(min_size, min_size)
func scale_ui_for_tablet() -> void: # Spread UI to edges, use horizontal space $LeftControls.position.x = 100 $RightControls.position.x = get_viewport_rect().size.x - 100
func scale_ui_for_phone() -> void: # Keep UI at bottom, vertically compact $LeftControls.position.y = get_viewport_rect().size.y - 200 $RightControls.position.y = get_viewport_rect().size.y - 200

---

Performance Optimization

性能优化

Mobile-Specific Settings

移动平台专属设置

gdscript
undefined
gdscript
undefined

project.godot or autoload

project.godot or autoload

extends Node
func _ready() -> void: if OS.get_name() in ["Android", "iOS"]: apply_mobile_optimizations()
func apply_mobile_optimizations() -> void: # Reduce rendering quality get_viewport().msaa_2d = Viewport.MSAA_DISABLED get_viewport().msaa_3d = Viewport.MSAA_DISABLED get_viewport().screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
# Lower shadow quality
RenderingServer.directional_shadow_atlas_set_size(2048, false)  # Down from 4096

# Reduce particle counts
for particle in get_tree().get_nodes_in_group("godot-particles"):
    if particle is GPUParticles2D:
        particle.amount = max(10, particle.amount / 2)

# Lower physics tick rate
Engine.physics_ticks_per_second = 30  # Down from 60

# Disable expensive effects
var env := get_viewport().world_3d.environment
if env:
    env.glow_enabled = false
    env.ssao_enabled = false
    env.ssr_enabled = false
undefined
extends Node
func _ready() -> void: if OS.get_name() in ["Android", "iOS"]: apply_mobile_optimizations()
func apply_mobile_optimizations() -> void: # Reduce rendering quality get_viewport().msaa_2d = Viewport.MSAA_DISABLED get_viewport().msaa_3d = Viewport.MSAA_DISABLED get_viewport().screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
# Lower shadow quality
RenderingServer.directional_shadow_atlas_set_size(2048, false)  # Down from 4096

# Reduce particle counts
for particle in get_tree().get_nodes_in_group("godot-particles"):
    if particle is GPUParticles2D:
        particle.amount = max(10, particle.amount / 2)

# Lower physics tick rate
Engine.physics_ticks_per_second = 30  # Down from 60

# Disable expensive effects
var env := get_viewport().world_3d.environment
if env:
    env.glow_enabled = false
    env.ssao_enabled = false
    env.ssr_enabled = false
undefined

Adaptive Performance

自适应性能

gdscript
undefined
gdscript
undefined

Dynamically adjust quality based on FPS

Dynamically adjust quality based on FPS

extends Node
@export var target_fps: int = 60 @export var check_interval: float = 2.0
var timer: float = 0.0 var quality_level: int = 2 # 0=low, 1=med, 2=high
func _process(delta: float) -> void: timer += delta if timer >= check_interval: var current_fps := Engine.get_frames_per_second()
    if current_fps < target_fps - 10 and quality_level > 0:
        quality_level -= 1
        apply_quality(quality_level)
    elif current_fps > target_fps + 5 and quality_level < 2:
        quality_level += 1
        apply_quality(quality_level)
    
    timer = 0.0
func apply_quality(level: int) -> void: match level: 0: # Low get_viewport().scaling_3d_scale = 0.5 1: # Medium get_viewport().scaling_3d_scale = 0.75 2: # High get_viewport().scaling_3d_scale = 1.0

---
extends Node
@export var target_fps: int = 60 @export var check_interval: float = 2.0
var timer: float = 0.0 var quality_level: int = 2 # 0=low, 1=med, 2=high
func _process(delta: float) -> void: timer += delta if timer >= check_interval: var current_fps := Engine.get_frames_per_second()
    if current_fps < target_fps - 10 and quality_level > 0:
        quality_level -= 1
        apply_quality(quality_level)
    elif current_fps > target_fps + 5 and quality_level < 2:
        quality_level += 1
        apply_quality(quality_level)
    
    timer = 0.0
func apply_quality(level: int) -> void: match level: 0: # Low get_viewport().scaling_3d_scale = 0.5 1: # Medium get_viewport().scaling_3d_scale = 0.75 2: # High get_viewport().scaling_3d_scale = 1.0

---

Battery Life Management

电池寿命管理

Background Behavior

后台行为

gdscript
undefined
gdscript
undefined

mobile_lifecycle.gd

mobile_lifecycle.gd

extends Node
func _ready() -> void: get_tree().on_request_permissions_result.connect(_on_permissions_result)
func _notification(what: int) -> void: match what: NOTIFICATION_APPLICATION_PAUSED: _on_app_backgrounded() NOTIFICATION_APPLICATION_RESUMED: _on_app_foregrounded()
func _on_app_backgrounded() -> void: # Reduce FPS drastically Engine.max_fps = 5
# Pause physics
get_tree().paused = true

# Stop audio
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), true)
func _on_app_foregrounded() -> void: # Restore FPS Engine.max_fps = 60
# Resume
get_tree().paused = false
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), false)

---
extends Node
func _ready() -> void: get_tree().on_request_permissions_result.connect(_on_permissions_result)
func _notification(what: int) -> void: match what: NOTIFICATION_APPLICATION_PAUSED: _on_app_backgrounded() NOTIFICATION_APPLICATION_RESUMED: _on_app_foregrounded()
func _on_app_backgrounded() -> void: # Reduce FPS drastically Engine.max_fps = 5
# Pause physics
get_tree().paused = true

# Stop audio
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), true)
func _on_app_foregrounded() -> void: # Restore FPS Engine.max_fps = 60
# Resume
get_tree().paused = false
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), false)

---

Platform-Specific Features

平台专属功能

Safe Area Insets (iPhone Notch)

安全区域内边距(iPhone刘海屏)

gdscript
undefined
gdscript
undefined

Handle notch/status bar

Handle notch/status bar

func _ready() -> void: if OS.get_name() == "iOS": var safe_area := DisplayServer.get_display_safe_area() var viewport_size := get_viewport_rect().size
    # Adjust UI margins
    $TopBar.position.y = safe_area.position.y
    $BottomControls.position.y = viewport_size.y - safe_area.end.y - 100
undefined
func _ready() -> void: if OS.get_name() == "iOS": var safe_area := DisplayServer.get_display_safe_area() var viewport_size := get_viewport_rect().size
    # Adjust UI margins
    $TopBar.position.y = safe_area.position.y
    $BottomControls.position.y = viewport_size.y - safe_area.end.y - 100
undefined

Vibration Feedback

振动反馈

gdscript
func trigger_haptic(intensity: float) -> void:
    if OS.has_feature("mobile"):
        # Android
        if OS.get_name() == "Android":
            var duration_ms := int(intensity * 100)
            OS.vibrate_handheld(duration_ms)
        
        # iOS (requires plugin)
        # Use third-party plugin for iOS haptics

gdscript
func trigger_haptic(intensity: float) -> void:
    if OS.has_feature("mobile"):
        # Android
        if OS.get_name() == "Android":
            var duration_ms := int(intensity * 100)
            OS.vibrate_handheld(duration_ms)
        
        # iOS (requires plugin)
        # Use third-party plugin for iOS haptics

Input Remapping

输入重映射

Mouse → Touch Conversion

鼠标 → 触摸转换

gdscript
undefined
gdscript
undefined

Desktop mouse input

Desktop mouse input

func _input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed: _on_click(event.position)
func _input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed: _on_click(event.position)

⬇️ Convert to touch:

⬇️ Convert to touch:

func _input(event: InputEvent) -> void: # Support both mouse (desktop testing) and touch if event is InputEventMouseButton and event.pressed: _on_click(event.position) elif event is InputEventScreenTouch and event.pressed: _on_click(event.position)
func _on_click(position: Vector2) -> void: # Handle click/tap pass

---
func _input(event: InputEvent) -> void: # Support both mouse (desktop testing) and touch if event is InputEventMouseButton and event.pressed: _on_click(event.position) elif event is InputEventScreenTouch and event.pressed: _on_click(event.position)
func _on_click(position: Vector2) -> void: # Handle click/tap pass

---

Edge Cases

边缘情况

Keyboard Popup Blocking UI

键盘弹出遮挡UI

gdscript
undefined
gdscript
undefined

Problem: Virtual keyboard covers text input

Problem: Virtual keyboard covers text input

Solution: Detect keyboard, scroll UI up

Solution: Detect keyboard, scroll UI up

func _on_text_edit_focus_entered() -> void: if OS.has_feature("mobile"): # Keyboard height varies; estimate 300px var keyboard_offset := 300 $UI.position.y -= keyboard_offset
func _on_text_edit_focus_exited() -> void: $UI.position.y = 0
undefined
func _on_text_edit_focus_entered() -> void: if OS.has_feature("mobile"): # Keyboard height varies; estimate 300px var keyboard_offset := 300 $UI.position.y -= keyboard_offset
func _on_text_edit_focus_exited() -> void: $UI.position.y = 0
undefined

Accidental Touch Inputs

误触输入

gdscript
undefined
gdscript
undefined

Problem: Palm resting on screen triggers inputs

Problem: Palm resting on screen triggers inputs

Solution: Ignore touches near screen edges

Solution: Ignore touches near screen edges

func is_valid_touch(position: Vector2) -> bool: var viewport_size := get_viewport_rect().size var edge_margin := 50.0
return (position.x > edge_margin and
        position.x < viewport_size.x - edge_margin and
        position.y > edge_margin and
        position.y < viewport_size.y - edge_margin)

---
func is_valid_touch(position: Vector2) -> bool: var viewport_size := get_viewport_rect().size var edge_margin := 50.0
return (position.x > edge_margin and
        position.x < viewport_size.x - edge_margin and
        position.y > edge_margin and
        position.y < viewport_size.y - edge_margin)

---

Testing Checklist

测试清单

  • Touch controls work with fat fingers (test on real device)
  • UI doesn't block gameplay-critical elements
  • Game pauses when app goes to background
  • Performance is 60 FPS on target device (iPhone 12, Galaxy S21)
  • Battery drain is < 10% per hour
  • Safe area respected (notch, status bar)
  • Works in both portrait and landscape
  • Text is readable on smallest target device (iPhone SE)
  • 触摸控制在粗手指操作下正常工作(需在真实设备上测试)
  • UI元素不会遮挡游戏关键内容
  • 应用进入后台时游戏自动暂停
  • 目标设备(iPhone 12、Galaxy S21)上性能可达60 FPS
  • 每小时电池消耗低于10%
  • 已适配安全区域(刘海屏、状态栏)
  • 横竖屏模式均能正常运行
  • 最小目标设备(iPhone SE)上文字清晰可读

Reference

参考

  • Master Skill: godot-master
  • 核心技能:godot-master