godot-adapt-desktop-to-mobile
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseAdapt: 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
决策矩阵
| Genre | Recommended Control | Example |
|---|---|---|
| Platformer | Virtual joystick (left) + jump button (right) | Super Mario Run |
| Top-down shooter | Dual-stick (move left, aim right) | Brawl Stars |
| Turn-based | Direct tap on units/tiles | Into the Breach |
| Puzzle | Tap, swipe, pinch gestures | Candy Crush |
| Card game | Drag-and-drop | Hearthstone |
| Racing | Tilt steering or tap left/right | Asphalt 9 |
| 游戏类型 | 推荐控制方式 | 示例 |
|---|---|---|
| 平台跳跃类 | 左侧虚拟摇杆 + 右侧跳跃按钮 | 《超级马里奥酷跑》 |
| 俯视角射击类 | 双摇杆(左摇杆移动,右摇杆瞄准) | 《荒野乱斗》 |
| 回合制策略类 | 直接点击单位/格子 | 《陷阵之志》 |
| 益智解谜类 | 点击、滑动、捏合手势 | 《糖果传奇》 |
| 卡牌类 | 拖拽操作 | 《炉石传说》 |
| 赛车类 | 重力感应或点击左右 | 《狂野飙车9》 |
Virtual Joystick
虚拟摇杆
gdscript
undefinedgdscript
undefinedvirtual_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)
undefinedextends 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)
undefinedGesture Detection
手势检测
gdscript
undefinedgdscript
undefinedgesture_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
undefinedgdscript
undefinedAdjust 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
undefinedgdscript
undefinedproject.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 = falseundefinedextends 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 = falseundefinedAdaptive Performance
自适应性能
gdscript
undefinedgdscript
undefinedDynamically 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.0func 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.0func 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
undefinedgdscript
undefinedmobile_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
undefinedgdscript
undefinedHandle 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 - 100undefinedfunc _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 - 100undefinedVibration 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 hapticsgdscript
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 hapticsInput Remapping
输入重映射
Mouse → Touch Conversion
鼠标 → 触摸转换
gdscript
undefinedgdscript
undefinedDesktop 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
undefinedgdscript
undefinedProblem: 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
undefinedfunc _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
undefinedAccidental Touch Inputs
误触输入
gdscript
undefinedgdscript
undefinedProblem: 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