godot-adapt-desktop-to-mobile by thedivergentai/gd-agentic-skills
npx skills add https://github.com/thedivergentai/gd-agentic-skills --skill godot-adapt-desktop-to-mobile将桌面游戏移植到移动平台的专家指南。
mouse_motion 替换为 screen_drag 并检查 InputEventScreenTouch.pressed。强制要求:在实现相应模式前,请阅读对应的脚本。
专家级动态虚拟摇杆,它会在用户触摸屏幕左半边的确切位置出现,而不是依赖固定的 UI 位置。
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
自适应视口缩放器,动态降低 scaling_3d_scale 以在性能较弱的 GPU 上维持 60FPS,同时保持 2D UI 完美清晰。
高级触摸手势识别器,跟踪持续时间、距离和多点触控比例,以输出精确的滑动和捏合缩放信号。
关键的生命周期管理器,挂钩 NOTIFICATION_APPLICATION_PAUSED,立即锁定 Engine.max_fps = 1 并暂停物理模拟,以防止操作系统因后台耗电而杀死应用。
动态 MarginContainer 脚本,查询 DisplayServer.get_display_safe_area(),自动为 iPhone 刘海屏和 Android 挖孔摄像头周围的 UI 元素添加内边距。
平滑的 Camera2D 控制器,同时结合单指相对平移和双指距离比例捏合缩放。
集中式的单例,为 Android 触发 Input.vibrate_handheld,并为 iOS 原生触觉插件演示钩子模式。
场景树遍历器,在性能较弱的移动端渲染器上,从 StandardMaterial3D 中移除昂贵的次表面散射、清漆涂层和动态着色效果。
监听 DisplayServer.virtual_keyboard_update,以补间方式上移整个 UI,防止系统键盘遮挡 LineEdit。
基于内存的保存字典,绕过 NOTIFICATION_WM_CLOSE_REQUEST(这在移动端应用被杀死时会失效),并保证在应用暂停生命周期期间进行加密的磁盘写入。
| 游戏类型 | 推荐控制方式 | 示例 |
|---|---|---|
| 平台跳跃 | 虚拟摇杆(左)+ 跳跃按钮(右) | Super Mario Run |
| 俯视角射击 | 双摇杆(左移动,右瞄准) | Brawl Stars |
| 回合制 | 直接点击单位/格子 | Into the Breach |
| 解谜 | 点击、滑动、捏合手势 | Candy Crush |
| 卡牌游戏 | 拖放 | Hearthstone |
| 赛车 | 倾斜转向或点击左/右 | Asphalt 9 |
# 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
# 限制最大距离
if offset.length() > max_distance:
offset = offset.normalized() * max_distance
knob.position = stick_center + offset
# 计算方向(-1 到 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_detector.gd
extends Node
signal swipe_detected(direction: Vector2) # 归一化
signal pinch_detected(scale: float) # > 1.0 = 放大
signal tap_detected(position: Vector2)
const SWIPE_THRESHOLD := 100.0 # 像素
const TAP_MAX_DISTANCE := 20.0
const TAP_MAX_DURATION := 0.3 # 秒
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
# 点击检测
if distance < TAP_MAX_DISTANCE and duration < TAP_MAX_DURATION:
tap_detected.emit(event.position)
# 滑动检测
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:
# 捏合检测(需要 2 个触摸点)
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
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
# 针对不同宽高比进行调整
if aspect_ratio > 2.0: # 超宽屏(平板横屏)
scale_ui_for_tablet()
elif aspect_ratio < 0.6: # 高屏(手机竖屏)
scale_ui_for_phone()
# 调整触摸按钮大小
for button in get_tree().get_nodes_in_group("touch_buttons"):
var min_size := 88 # 44pt * 2 用于 Retina 屏幕
button.custom_minimum_size = Vector2(min_size, min_size)
func scale_ui_for_tablet() -> void:
# 将 UI 分散到边缘,利用水平空间
$LeftControls.position.x = 100
$RightControls.position.x = get_viewport_rect().size.x - 100
func scale_ui_for_phone() -> void:
# 将 UI 保持在底部,垂直方向紧凑
$LeftControls.position.y = get_viewport_rect().size.y - 200
$RightControls.position.y = get_viewport_rect().size.y - 200
# project.godot 或自动加载
extends Node
func _ready() -> void:
if OS.get_name() in ["Android", "iOS"]:
apply_mobile_optimizations()
func apply_mobile_optimizations() -> void:
# 降低渲染质量
get_viewport().msaa_2d = Viewport.MSAA_DISABLED
get_viewport().msaa_3d = Viewport.MSAA_DISABLED
get_viewport().screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
# 降低阴影质量
RenderingServer.directional_shadow_atlas_set_size(2048, false) # 从 4096 降低
# 减少粒子数量
for particle in get_tree().get_nodes_in_group("godot-particles"):
if particle is GPUParticles2D:
particle.amount = max(10, particle.amount / 2)
# 降低物理模拟频率
Engine.physics_ticks_per_second = 30 # 从 60 降低
# 禁用昂贵的特效
var env := get_viewport().world_3d.environment
if env:
env.glow_enabled = false
env.ssao_enabled = false
env.ssr_enabled = false
# 基于 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=低, 1=中, 2=高
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: # 低
get_viewport().scaling_3d_scale = 0.5
1: # 中
get_viewport().scaling_3d_scale = 0.75
2: # 高
get_viewport().scaling_3d_scale = 1.0
# 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:
# 大幅降低 FPS
Engine.max_fps = 5
# 暂停物理模拟
get_tree().paused = true
# 停止音频
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), true)
func _on_app_foregrounded() -> void:
# 恢复 FPS
Engine.max_fps = 60
# 恢复
get_tree().paused = false
AudioServer.set_bus_mute(AudioServer.get_bus_index("Master"), false)
# 处理刘海屏/状态栏
func _ready() -> void:
if OS.get_name() == "iOS":
var safe_area := DisplayServer.get_display_safe_area()
var viewport_size := get_viewport_rect().size
# 调整 UI 边距
$TopBar.position.y = safe_area.position.y
$BottomControls.position.y = viewport_size.y - safe_area.end.y - 100
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(需要插件)
# 使用第三方插件实现 iOS 触觉反馈
# 桌面鼠标输入
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)
elif event is InputEventScreenTouch and event.pressed:
_on_click(event.position)
func _on_click(position: Vector2) -> void:
# 处理点击/触摸
pass
# 问题:虚拟键盘覆盖文本输入框
# 解决方案:检测键盘,向上滚动 UI
func _on_text_edit_focus_entered() -> void:
if OS.has_feature("mobile"):
# 键盘高度各异;估计为 300px
var keyboard_offset := 300
$UI.position.y -= keyboard_offset
func _on_text_edit_focus_exited() -> void:
$UI.position.y = 0
# 问题:手掌靠在屏幕上会触发输入
# 解决方案:忽略屏幕边缘附近的触摸
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)
每周安装次数
73
代码仓库
GitHub 星标数
59
首次出现
2026年2月10日
安全审计
安装于
opencode72
gemini-cli71
codex71
amp68
kimi-cli68
github-copilot68
Expert guidance for porting desktop games to mobile platforms.
MANDATORY : Read the appropriate script before implementing the corresponding pattern.
Expert Dynamic Virtual Joystick that appears exactly where the user touches the left half of the screen instead of relying on fixed UI positions.
Adaptive Viewport scaler that dynamically drops the scaling_3d_scale to maintain 60FPS on weak GPUs while keeping the 2D UI perfectly sharp.
Advanced touch gesture recognizer tracking duration, distance, and multi-touch ratios to output precise swipe and pinch-to-zoom signals.
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.
Dynamic MarginContainer script querying DisplayServer.get_display_safe_area() to automatically pad UI elements around iPhone notches and Android hole-punch cameras.
Smooth Camera2D controller combining 1-finger relative panning and 2-finger distance-ratio pinch zooming simultaneously.
Centralized singleton triggering Input.vibrate_handheld for Android, and demonstrating the hook pattern for iOS native haptic plugins.
SceneTree crawler that strips expensive sub-surface scattering, clearcoats, and dynamic shading from StandardMaterial3D on weak mobile renderers.
Listens to DisplayServer.virtual_keyboard_update to tween the entire UI upward, preventing the OS keyboard from occluding LineEdits.
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.
| 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.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_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
# 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
# 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
# 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
# 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)
# 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
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
# 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
# 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
# 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)
Weekly Installs
73
Repository
GitHub Stars
59
First Seen
Feb 10, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode72
gemini-cli71
codex71
amp68
kimi-cli68
github-copilot68
ESLint迁移到Oxlint完整指南:JavaScript/TypeScript项目性能优化工具
1,700 周安装
Doublecheck AI 内容验证工具 - GitHub Copilot 三层事实核查流程,自动识别幻觉风险
1,100 周安装
Everything Claude 代码规范:JavaScript 项目开发规范与提交指南
1,100 周安装
Web开发最佳实践指南:Lighthouse安全、兼容性与代码质量优化
72 周安装
UI/UX Pro Max - 智能设计助手:配色、字体、图表与最佳实践数据库
1,100 周安装
艺术家工作区搭建指南 - 快速创建智能体工作环境与目录结构
1,100 周安装
Binance 兑换 API 使用指南 - 加密货币交易与兑换开发技能
1,100 周安装