
Godot Adapt Desktop To Mobile
- 184 installs
- 454 repo stars
- Updated July 28, 2026
- thedivergentai/gd-agentic-skills
Use godot-adapt-desktop-to-mobile for development tasks
About
godot-adapt-desktop-to-mobile: A skill for development. This provides functionality for development workflows.
- godot-adapt-desktop-to-mobile
Godot Adapt Desktop To Mobile by the numbers
- 184 all-time installs (skills.sh)
- +10 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #2,160 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/thedivergentai/gd-agentic-skills --skill godot-adapt-desktop-to-mobileAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 184 |
|---|---|
| repo stars | ★ 454 |
| Last updated | July 28, 2026 |
| Repository | thedivergentai/gd-agentic-skills ↗ |
What it does
Use godot-adapt-desktop-to-mobile for development tasks
Files
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
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
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
Advanced touch gesture recognizer tracking duration, distance, and multi-touch ratios to output precise swipe and pinch-to-zoom signals.
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
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
Smooth Camera2D controller combining 1-finger relative panning and 2-finger distance-ratio pinch zooming simultaneously.
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
SceneTree crawler that strips expensive sub-surface scattering, clearcoats, and dynamic shading from StandardMaterial3D on weak mobile renderers.
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
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
# 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
# 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
# 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
# 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 = falseAdaptive Performance
# 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
# 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)
# 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 - 100Vibration Feedback
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
# 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
# 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 = 0Accidental Touch Inputs
# 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.
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.
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.
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
extends Node
class_name BatterySaverMode
## Expert Mobile Battery Management
## When the user swipes up or puts the phone to sleep, the OS pauses the app.
## If not handled carefully, continuous calculations will drain the battery and the OS will kill the app.
func _ready() -> void:
# Runs during all pauses so it can listen for resumes
process_mode = Node.PROCESS_MODE_ALWAYS
func _notification(what: int) -> void:
match what:
NOTIFICATION_APPLICATION_PAUSED, NOTIFICATION_WM_WINDOW_FOCUS_OUT:
_on_app_backgrounded()
NOTIFICATION_APPLICATION_RESUMED, NOTIFICATION_WM_WINDOW_FOCUS_IN:
_on_app_foregrounded()
func _on_app_backgrounded() -> void:
# 1. Pause game logic and physics
get_tree().paused = true
# 2. Aggressively drop frame rate to save GPU/CPU cycles
# Godot will render 1 frame per second to keep the OS snapshot looking correct
Engine.max_fps = 1
# 3. Mute all master volume so it doesn't try to play over music
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), true)
print("BatterySaver: App suspended. Physics paused, FPS locked to 1.")
func _on_app_foregrounded() -> void:
# Resume everything
get_tree().paused = false
# Setting to 0 unlocks the framerate (or locks to VSync)
Engine.max_fps = 0
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), false)
print("BatterySaver: App resumed. Physics active, FPS restored.")
extends Control
class_name DynamicVirtualJoystick
## Expert Virtual Joystick Spawner
## Instead of a hardcoded bottom-left position, this joystick appears
## exactly where the user touches on the left half of the screen.
signal joystick_updated(direction: Vector2)
@export var max_radius: float = 100.0
@export var return_speed: float = 20.0
@onready var background: Sprite2D = $Background
@onready var handle: Sprite2D = $Handle
var active_touch_index: int = -1
var joystick_center: Vector2 = Vector2.ZERO
func _ready() -> void:
modulate.a = 0.0 # Hidden by default
func _input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
# Only activate on the left half of the screen
if event.pressed and active_touch_index == -1 and event.position.x < get_viewport_rect().size.x / 2.0:
active_touch_index = event.index
joystick_center = event.position
global_position = joystick_center
handle.position = Vector2.ZERO
modulate.a = 1.0 # Show
elif not event.pressed and event.index == active_touch_index:
active_touch_index = -1
modulate.a = 0.0 # Hide
joystick_updated.emit(Vector2.ZERO)
elif event is InputEventScreenDrag and event.index == active_touch_index:
var drag_vector = event.position - joystick_center
var clamped_drag = drag_vector.limit_length(max_radius)
handle.position = clamped_drag
var normalized_direction = clamped_drag / max_radius
joystick_updated.emit(normalized_direction)
extends Node
class_name GestureComboSystem
## Expert Swipe and Pinch Gesture Recognizer
## Tracks touch start/end times and calculates velocity for flicks.
signal swipe_detected(direction: Vector2, velocity: float)
signal zoom_detected(scale_factor: float) # <1 for pinch out, >1 for pinch in
var touches: Dictionary = {}
func _input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
if event.pressed:
touches[event.index] = {
"start_pos": event.position,
"current_pos": event.position,
"time": Time.get_ticks_msec()
}
else:
_analyze_release(event)
touches.erase(event.index)
elif event is InputEventScreenDrag:
if touches.has(event.index):
touches[event.index]["current_pos"] = event.position
_analyze_drag()
func _analyze_release(event: InputEventScreenTouch) -> void:
if touches.size() != 1: return # Ignore single swipes if multiple fingers down
var data = touches[event.index]
var distance = event.position.distance_to(data.start_pos)
var duration = (Time.get_ticks_msec() - data.time) / 1000.0
# 50 pixels minimum distance, < 0.3 seconds duration to count as swipe
if distance > 50.0 and duration < 0.3:
var direction = (event.position - data.start_pos).normalized()
var velocity = distance / duration
swipe_detected.emit(direction, velocity)
func _analyze_drag() -> void:
if touches.size() == 2:
var keys = touches.keys()
var touch1 = touches[keys[0]]
var touch2 = touches[keys[1]]
var start_dist = touch1.start_pos.distance_to(touch2.start_pos)
var current_dist = touch1.current_pos.distance_to(touch2.current_pos)
if start_dist > 10.0:
var scale_factor = current_dist / start_dist
zoom_detected.emit(scale_factor)
class_name HapticManager
extends Node
## Expert Mobile Haptic Feedback Manager
## Godot supports Android vibration out of the box.
## iOS requires specific native plugins (like iOSHaptics), but we abstract the concept here.
enum HapticType {
LIGHT_TICK,
MEDIUM_BUMP,
HEAVY_CRASH,
SUCCESS_BUZZ
}
static func trigger(type: HapticType) -> void:
if not OS.has_feature("mobile"):
return
var duration_ms: int = 0
match type:
HapticType.LIGHT_TICK:
duration_ms = 15
HapticType.MEDIUM_BUMP:
duration_ms = 40
HapticType.HEAVY_CRASH:
duration_ms = 100
HapticType.SUCCESS_BUZZ:
duration_ms = 250
# Native Android Vibration
if OS.get_name() == "Android":
Input.vibrate_handheld(duration_ms)
# Example iOS Plugin Hook (assuming a plugin named "iOSHaptics" is installed)
elif OS.get_name() == "iOS":
if Engine.has_singleton("iOSHaptics"):
var ios_haptics = Engine.get_singleton("iOSHaptics")
# Usually plugins map to UI selection, impact, or notification haptics
ios_haptics.impact(int(type))
extends Node
class_name MobileShaderFallback
## Expert Mobile Material Optimizer
## Highly complex shaders (StandardMaterial3D with SSS, Detail textures, CLEARCOAT)
## will crush weak mobile Adreno/Mali GPUs. This script automatically crawls the
## SceneTree and replaces complex materials with cheap Unshaded or Vertex-Lit equivalents.
@export var optimize_on_startup: bool = true
func _ready() -> void:
if OS.has_feature("mobile") and optimize_on_startup:
# Start the recursive search down the tree
_downgrade_materials(get_tree().root)
func _downgrade_materials(node: Node) -> void:
if node is MeshInstance3D:
_optimize_mesh_instance(node)
for child in node.get_children():
_downgrade_materials(child)
func _optimize_mesh_instance(mesh_inst: MeshInstance3D) -> void:
for i in range(mesh_inst.get_surface_override_material_count()):
var mat = mesh_inst.get_surface_override_material(i)
if mat and mat is StandardMaterial3D:
_strip_expensive_features(mat)
func _strip_expensive_features(mat: StandardMaterial3D) -> void:
# Disable features that destroy mobile tiled renderers
mat.clearcoat_enabled = false
mat.subsurf_scatter_enabled = false
mat.detail_enabled = false
mat.distance_fade_mode = BaseMaterial3D.DISTANCE_FADE_DISABLED
# If the material doesn't need to react to light dynamically, unshade it completely
if mat.emission_enabled:
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
# skills/adapt-desktop-to-mobile/scripts/mobile_ui_adapter.gd
extends CanvasLayer
## Mobile UI Adapter Expert Pattern
## Auto-detects mobile platform and creates touch controls / scales UI.
class_name MobileUIAdapter
@export var virtual_joystick_scene: PackedScene
@export var touch_button_group: Control # Container for right-side buttons
func _ready() -> void:
if _is_mobile():
_enable_mobile_controls()
_scale_ui_elements()
else:
_disable_mobile_controls()
func _is_mobile() -> bool:
return OS.has_feature("mobile") or OS.has_feature("web_android") or OS.has_feature("web_ios")
func _enable_mobile_controls() -> void:
if virtual_joystick_scene:
var stick = virtual_joystick_scene.instantiate()
add_child(stick)
# Position bottom-left
stick.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
stick.position += Vector2(50, -50) # Margin
if touch_button_group:
touch_button_group.show()
func _disable_mobile_controls() -> void:
if touch_button_group:
touch_button_group.hide()
func _scale_ui_elements() -> void:
# Iterate over specific groups to scale up for touch
var buttons = get_tree().get_nodes_in_group("touch_interactive")
for btn in buttons:
if btn is Control:
# Ensure min 44pt (approx 88px on high DPI)
var min_size = 88.0
if btn.custom_minimum_size.x < min_size:
btn.custom_minimum_size.x = min_size
if btn.custom_minimum_size.y < min_size:
btn.custom_minimum_size.y = min_size
## EXPERT USAGE:
## Add to Main Scene. Assign Virtual Joystick scene.
## Group small buttons as "touch_interactive" to auto-scale them.
class_name OfflineSaveSync
extends Node
## Expert Mobile Save Data Manager
## Mobile OSs will aggressively terminate background apps.
## You CANNOT rely on "save on exit" signals like `NOTIFICATION_WM_CLOSE_REQUEST` on iOS/Android.
## You must save immediately during `NOTIFICATION_APPLICATION_PAUSED` or proactively during gameplay.
const SAVE_PATH = "user://mobile_save.dat"
var game_data: Dictionary = {}
func _ready() -> void:
process_mode = Node.PROCESS_MODE_ALWAYS
_load_game_data()
func _notification(what: int) -> void:
match what:
# App is being swiped away or put to sleep. SAVE NOW.
NOTIFICATION_APPLICATION_PAUSED, NOTIFICATION_WM_WINDOW_FOCUS_OUT:
_force_save_to_disk()
## Instead of saving to disk every time a coin is picked up (which causes stutter/battery drain),
## we update a memory Dictionary and only write to disk when backgrounded.
func update_data(key: String, value: Variant) -> void:
game_data[key] = value
func _force_save_to_disk() -> void:
var file = FileAccess.open_encrypted_with_pass(SAVE_PATH, FileAccess.WRITE, "secure_mobile_key_123!")
if file:
file.store_var(game_data)
file.close()
print("OfflineSave: Successfully saved during app pause.")
else:
push_error("OfflineSave: Failed to open save file during pause!")
func _load_game_data() -> void:
if FileAccess.file_exists(SAVE_PATH):
var file = FileAccess.open_encrypted_with_pass(SAVE_PATH, FileAccess.READ, "secure_mobile_key_123!")
if file:
game_data = file.get_var()
file.close()
extends Control
class_name OnScreenKeyboardHandler
## Expert Virtual Keyboard Management
## When a LineEdit or TextEdit is focused on mobile, the OS pops up a virtual keyboard
## that covers the bottom 30-50% of the screen. We must push the UI UP to keep the input visible.
@export var layout_container: Control # The main UI root to shift
func _ready() -> void:
# Connect to the OS virtual keyboard signals
DisplayServer.virtual_keyboard_update.connect(_on_virtual_keyboard_changed)
func _on_virtual_keyboard_changed(keyboard_height: float) -> void:
if not OS.has_feature("mobile"): return
if keyboard_height > 0:
# Keyboard opened.
# Push the UI up by the height of the keyboard
var tween = create_tween()
tween.tween_property(layout_container, "position:y", -keyboard_height, 0.2).set_trans(Tween.TRANS_SINE)
else:
# Keyboard closed. Return UI to normal.
var tween = create_tween()
tween.tween_property(layout_container, "position:y", 0.0, 0.2).set_trans(Tween.TRANS_SINE)
## Automatically close the keyboard if the user taps outside the LineEdit
func _input(event: InputEvent) -> void:
if event is InputEventScreenTouch and event.pressed:
var focus_owner = get_viewport().gui_get_focus_owner()
if focus_owner and (focus_owner is LineEdit or focus_owner is TextEdit):
# If they didn't touch the text box, release focus and hide keyboard
var local_event = focus_owner.make_input_local(event)
if not focus_owner.get_rect().has_point(local_event.position):
focus_owner.release_focus()
DisplayServer.virtual_keyboard_hide()
extends Node
class_name AdaptiveMobileResolution
## Expert Viewport Resolution Scaler
## Separates UI rendering from 3D world rendering.
## Dynamically drops the 3D resolution scale down to keep 60 FPS on weak GPUs
## while leaving the 2D UI incredibly sharp.
@export var target_fps: int = 60
@export var check_interval: float = 2.0
@export var min_scale: float = 0.3
@export var scale_step: float = 0.1
var timer: float = 0.0
func _ready() -> void:
# Requires Project Settings -> Display -> Window -> Stretch -> Mode: 'canvas_items'
if not OS.has_feature("mobile"):
set_process(false)
func _process(delta: float) -> void:
timer += delta
if timer >= check_interval:
timer = 0.0
var current_fps = Engine.get_frames_per_second()
var current_scale = get_viewport().scaling_3d_scale
if current_fps < target_fps - 5:
# Dropping frames, decrease 3D resolution
var new_scale = clampf(current_scale - scale_step, min_scale, 1.0)
get_viewport().scaling_3d_scale = new_scale
elif current_fps > target_fps + 2 and current_scale < 1.0:
# Plenty of headroom, try increasing resolution
var new_scale = clampf(current_scale + (scale_step / 2.0), min_scale, 1.0)
get_viewport().scaling_3d_scale = new_scale
extends Camera2D
class_name TouchPanZoomCamera2D
## Expert Touch Camera Controller
## Handles 1-finger panning and 2-finger pinch zooming simultaneously.
@export var min_zoom := 0.5
@export var max_zoom := 3.0
@export var pan_speed := 1.0
var touches: Dictionary = {}
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
if event.pressed:
touches[event.index] = event.position
else:
touches.erase(event.index)
elif event is InputEventScreenDrag:
touches[event.index] = event.position
if touches.size() == 1:
# Panning
# For Camera2D, we subtract the relative movement to drag the world across the screen
position -= event.relative * pan_speed * (1.0 / zoom.x)
elif touches.size() == 2:
# Pinch to Zoom
var keys = touches.keys()
var t1 = touches[keys[0]]
var t2 = touches[keys[1]]
var previous_dist = t1.distance_to(t2)
# Since event.relative is the change this frame, we reconstruct the previous positions
var prev_t1 = touches[keys[0]]
var prev_t2 = touches[keys[1]]
if event.index == keys[0]: prev_t1 -= event.relative
else: prev_t2 -= event.relative
var old_dist = prev_t1.distance_to(prev_t2)
# Ratio of new distance over old distance
var zoom_factor = previous_dist / max(old_dist, 0.001)
_apply_zoom(zoom_factor)
func _apply_zoom(factor: float) -> void:
var new_zoom = zoom.x * factor
new_zoom = clampf(new_zoom, min_zoom, max_zoom)
zoom = Vector2(new_zoom, new_zoom)
extends MarginContainer
class_name UISafeAreaMargins
## Expert Mobile Notch Avoidance
## On iPhone and Android, screens have physical cutouts (notches, hole punches).
## Instead of guessing, we use the OS safe area Rect2 to apply Margins accurately.
func _ready() -> void:
get_viewport().size_changed.connect(_apply_safe_area)
_apply_safe_area()
func _apply_safe_area() -> void:
if not OS.has_feature("mobile"):
return
var safe_area: Rect2i = DisplayServer.get_display_safe_area()
var window_size: Vector2i = DisplayServer.window_get_size()
var left_margin = safe_area.position.x
var top_margin = safe_area.position.y
var right_margin = window_size.x - safe_area.end.x
var bottom_margin = window_size.y - safe_area.end.y
add_theme_constant_override("margin_left", left_margin)
add_theme_constant_override("margin_top", top_margin)
add_theme_constant_override("margin_right", right_margin)
add_theme_constant_override("margin_bottom", bottom_margin)
# skills/adapt-desktop-to-mobile/scripts/virtual_joystick.gd
extends Control
## Virtual Joystick Expert Pattern
## Production-ready joystick with multi-touch support and visual feedback.
class_name VirtualJoystick
signal direction_changed(direction: Vector2)
@export var dead_zone: float = 0.2
@export var max_distance: float = 100.0
@export var clamp_zone: bool = true
# UI References
@onready var base: Sprite2D = $Base
@onready var knob: Sprite2D = $Knob
# State
var _touch_index: int = -1
var _stick_center: Vector2
func _ready() -> void:
# Ensure pivots are centered
if base: _stick_center = base.position
func _input(event: InputEvent) -> void:
if not (base and knob): return
if event is InputEventScreenTouch:
if event.pressed:
if _touch_index == -1 and _is_point_inside_base(event.position):
_touch_index = event.index
_update_joystick(event.position)
elif event.index == _touch_index:
_reset_joystick()
elif event is InputEventScreenDrag:
if event.index == _touch_index:
_update_joystick(event.position)
func _is_point_inside_base(point: Vector2) -> bool:
var global_rect = base.get_rect()
global_rect.position += base.global_position - (base.texture.get_size() / 2.0)
return global_rect.has_point(point)
func _update_joystick(touch_pos: Vector2) -> void:
var local_pos = to_local(touch_pos)
var vector = local_pos - _stick_center
var dist = vector.length()
if clamp_zone and dist > max_distance:
vector = vector.normalized() * max_distance
knob.position = _stick_center + vector
var output = vector / max_distance
if output.length() < dead_zone:
output = Vector2.ZERO
direction_changed.emit(output)
func _reset_joystick() -> void:
_touch_index = -1
knob.position = _stick_center
direction_changed.emit(Vector2.ZERO)
## EXPERT USAGE:
## Instantiate in MobileHUD. Connect 'direction_changed' to Player movement.