--- name: godot-adapt-desktop-to-mobile description: "Expert patterns for porting desktop games to mobile including touch control schemes (virtual joystick, gesture detection), UI scaling for small screens, performance optimization for mobile GPUs, battery life management, and platform-specific features. Use when creating mobile ports or cross-platform mobile builds. Trigger keywords: TouchScreenButton, virtual_joystick, gesture_detector, InputEventScreenTouch, InputEventScreenDrag, mobile_optimization, battery_saving, adaptive_performance, MOBILE_ENABLED." --- # 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. --- ## Available Scripts > **MANDATORY**: Read the appropriate script before implementing the corresponding pattern. ### [dynamic_joystick_spawner.gd](scripts/dynamic_joystick_spawner.gd) Expert Dynamic Virtual Joystick that appears exactly where the user touches the left half of the screen instead of relying on fixed UI positions. ### [resolution_scaler.gd](scripts/resolution_scaler.gd) Adaptive Viewport scaler that dynamically drops the `scaling_3d_scale` to maintain 60FPS on weak GPUs while keeping the 2D UI perfectly sharp. ### [gesture_combo_system.gd](scripts/gesture_combo_system.gd) Advanced touch gesture recognizer tracking duration, distance, and multi-touch ratios to output precise swipe and pinch-to-zoom signals. ### [battery_saver_mode.gd](scripts/battery_saver_mode.gd) Crucial lifecycle manager that hooks `NOTIFICATION_APPLICATION_PAUSED` to instantly lock `Engine.max_fps = 1` and pause physics to prevent the OS from killing the app due to background battery drain. ### [ui_safe_area_margins.gd](scripts/ui_safe_area_margins.gd) Dynamic MarginContainer script querying `DisplayServer.get_display_safe_area()` to automatically pad UI elements around iPhone notches and Android hole-punch cameras. ### [touch_camera_pan_zoom.gd](scripts/touch_camera_pan_zoom.gd) Smooth Camera2D controller combining 1-finger relative panning and 2-finger distance-ratio pinch zooming simultaneously. ### [haptic_feedback_manager.gd](scripts/haptic_feedback_manager.gd) Centralized singleton triggering `Input.vibrate_handheld` for Android, and demonstrating the hook pattern for iOS native haptic plugins. ### [mobile_shader_fallback.gd](scripts/mobile_shader_fallback.gd) SceneTree crawler that strips expensive sub-surface scattering, clearcoats, and dynamic shading from `StandardMaterial3D` on weak mobile renderers. ### [on_screen_keyboard_handler.gd](scripts/on_screen_keyboard_handler.gd) Listens to `DisplayServer.virtual_keyboard_update` to tween the entire UI upward, preventing the OS keyboard from occluding LineEdits. ### [offline_save_sync.gd](scripts/offline_save_sync.gd) Memory-based save dictionary that bypasses `NOTIFICATION_WM_CLOSE_REQUEST` (which fails on mobile kill) and guarantees encrypted disk writes during the App Pause lifecycle. --- ## 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 | ### Virtual Joystick ```gdscript # 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) ``` ### Gesture Detection ```gdscript # 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 ``` --- ## UI Scaling ### Responsive Layout ```gdscript # 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 ``` --- ## Performance Optimization ### Mobile-Specific Settings ```gdscript # 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 ``` ### Adaptive Performance ```gdscript # 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 ``` --- ## Battery Life Management ### Background Behavior ```gdscript # 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) ``` --- ## Platform-Specific Features ### Safe Area Insets (iPhone Notch) ```gdscript # 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 ``` ### 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 ``` --- ## Input Remapping ### Mouse → Touch Conversion ```gdscript # Desktop mouse input func _input(event: InputEvent) -> void: if event is InputEventMouseButton and event.pressed: _on_click(event.position) # ⬇️ 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 ``` --- ## Edge Cases ### Keyboard Popup Blocking UI ```gdscript # Problem: Virtual keyboard covers text input # 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 ``` ### Accidental Touch Inputs ```gdscript # Problem: Palm resting on screen triggers inputs # 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) ``` --- ## 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) ## Expert Techniques & Optimizations ### 1. Unified IAP Manager (iOS & Android) Mobile In-App Purchases (IAP) rely on platform-specific singletons (`GodotGooglePlayBilling` for Android and `InAppStore` for iOS). Use feature tags to abstract these behind a unified interface. ```gdscript class_name IAPManager extends Node var android_billing: Object var ios_store: Object func _ready() -> void: if OS.has_feature("android") and Engine.has_singleton("GodotGooglePlayBilling"): android_billing = Engine.get_singleton("GodotGooglePlayBilling") android_billing.start_connection() elif OS.has_feature("ios") and Engine.has_singleton("InAppStore"): ios_store = Engine.get_singleton("InAppStore") ios_store.set_auto_finish_transaction(true) func purchase_product(id: String) -> void: if android_billing: android_billing.purchase(id) elif ios_store: ios_store.purchase({ "product_id": id }) ``` ### 2. App Store Asset Pipeline (Screenshot Automation) Generating store screenshots across multiple resolutions is tedious. Automate this by capturing the viewport texture after the frame is drawn. ```gdscript func capture_screenshot(filename: String) -> void: # Wait for the frame to finish rendering await RenderingServer.frame_post_draw var img: Image = get_viewport().get_texture().get_image() var path := "user://screenshots/%s.png" % filename img.save_png(path) print("Screenshot saved to: ", path) ``` ### 3. Mobile Debug Overlay (Memory Monitoring) Mobile devices have strict memory limits. Use `OS.get_memory_info()` to monitor physical and available memory in real-time. ```gdscript func _process(_delta: float) -> void: var mem: Dictionary = OS.get_memory_info() var physical_mb := float(mem.get("physical", 0)) / 1048576.0 var available_mb := float(mem.get("available", 0)) / 1048576.0 $DebugLabel.text = "Mem: %.2f / %.2f MB" % [available_mb, physical_mb] ``` ## Reference - Master Skill: [godot-master](../godot-master/SKILL.md)