Initial Void Bastion 0.20 prototype
This commit is contained in:
commit
7362ca0f02
251 files changed
+29688
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
uid://dssu2uimllcg6
|
||||
@@ -0,0 +1,374 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
const PLUGIN_NAME: String = "AdmobPlugin"
|
||||
const ANDROID_DEPENDENCIES: Array = [ "androidx.appcompat:appcompat:1.7.1", "androidx.lifecycle:lifecycle-process:2.8.3", "com.google.android.gms:play-services-ads:24.9.0" ]
|
||||
const IOS_PLATFORM_VERSION: String = "14.3"
|
||||
const IOS_FRAMEWORKS: Array = [ "Foundation.framework", "AppTrackingTransparency.framework" ]
|
||||
const IOS_EMBEDDED_FRAMEWORKS: Array = [ ]
|
||||
const IOS_LINKER_FLAGS: Array = [ "-ObjC" ]
|
||||
const IOS_BUNDLE_FILES: Array = [ ]
|
||||
const SPM_DEPENDENCIES: Array = [ {&"url": "https://github.com/googleads/swift-package-manager-google-mobile-ads.git", &"version": "12.14.0", &"products": ["GoogleMobileAds"]} ]
|
||||
|
||||
var android_export_plugin: AndroidExportPlugin
|
||||
var ios_export_plugin: IosExportPlugin
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
android_export_plugin = AndroidExportPlugin.new()
|
||||
add_export_plugin(android_export_plugin)
|
||||
ios_export_plugin = IosExportPlugin.new()
|
||||
add_export_plugin(ios_export_plugin)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
remove_export_plugin(android_export_plugin)
|
||||
android_export_plugin = null
|
||||
remove_export_plugin(ios_export_plugin)
|
||||
ios_export_plugin = null
|
||||
|
||||
|
||||
class AndroidExportPlugin extends EditorExportPlugin:
|
||||
const APP_ID_META_TAG = """
|
||||
<meta-data
|
||||
tools:replace="android:value"
|
||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||
android:value="%s"/>
|
||||
"""
|
||||
|
||||
var _plugin_name = PLUGIN_NAME
|
||||
var _export_config: AdmobAndroidExportConfig
|
||||
|
||||
|
||||
func _supports_platform(platform: EditorExportPlatform) -> bool:
|
||||
return platform is EditorExportPlatformAndroid
|
||||
|
||||
|
||||
func _get_android_libraries(platform: EditorExportPlatform, debug: bool) -> PackedStringArray:
|
||||
if debug:
|
||||
return PackedStringArray(["%s/bin/debug/%s-debug.aar" % [_plugin_name, _plugin_name]])
|
||||
else:
|
||||
return PackedStringArray(["%s/bin/release/%s-release.aar" % [_plugin_name, _plugin_name]])
|
||||
|
||||
|
||||
func _get_name() -> String:
|
||||
return _plugin_name
|
||||
|
||||
|
||||
func _export_begin(features: PackedStringArray, is_debug: bool, path: String, flags: int) -> void:
|
||||
if _supports_platform(get_export_platform()):
|
||||
_export_config = AdmobAndroidExportConfig.new()
|
||||
if not _export_config.export_config_file_exists() or _export_config.load_export_config_from_file() != OK:
|
||||
_export_config.load_export_config_from_node()
|
||||
|
||||
|
||||
func _get_android_dependencies(platform: EditorExportPlatform, debug: bool) -> PackedStringArray:
|
||||
var deps: PackedStringArray = PackedStringArray(ANDROID_DEPENDENCIES)
|
||||
if _export_config and _export_config.enabled_mediation_networks.size() > 0:
|
||||
for __network in _export_config.enabled_mediation_networks:
|
||||
for __dependency in __network.android_dependencies:
|
||||
deps.append(__dependency)
|
||||
|
||||
GmpLogger.log_info("Android dependencies: %s" % str(deps))
|
||||
|
||||
return deps
|
||||
|
||||
|
||||
func _get_android_dependencies_maven_repos(platform: EditorExportPlatform, debug: bool) -> PackedStringArray:
|
||||
var __custom_repos: PackedStringArray = []
|
||||
|
||||
if _export_config and _export_config.enabled_mediation_networks.size() > 0:
|
||||
for network in _export_config.enabled_mediation_networks:
|
||||
if network.android_custom_maven_repo and not network.android_custom_maven_repo.is_empty():
|
||||
__custom_repos.append(network.android_custom_maven_repo)
|
||||
GmpLogger.log_info(
|
||||
"Added custom Maven repo for %s mediation: %s" %
|
||||
[network.tag, network.android_custom_maven_repo],
|
||||
)
|
||||
|
||||
return __custom_repos
|
||||
|
||||
|
||||
func _get_android_manifest_application_element_contents(platform: EditorExportPlatform, debug: bool) -> String:
|
||||
var __contents: String
|
||||
|
||||
if _export_config:
|
||||
__contents = APP_ID_META_TAG % (_export_config.real_application_id if _export_config.is_real \
|
||||
else _export_config.debug_application_id )
|
||||
else:
|
||||
GmpLogger.log_warn("Export config not found for %s!" % _plugin_name)
|
||||
__contents = ""
|
||||
|
||||
return __contents
|
||||
|
||||
|
||||
class IosExportPlugin extends EditorExportPlugin:
|
||||
const NS_APP_TRANSPORT_SECURITY: String = """
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
"""
|
||||
|
||||
var _plugin_name = PLUGIN_NAME
|
||||
var _spm_dependencies = []
|
||||
var _export_config: AdmobIosExportConfig
|
||||
var _export_path: String
|
||||
|
||||
|
||||
func _supports_platform(platform: EditorExportPlatform) -> bool:
|
||||
return platform is EditorExportPlatformIOS
|
||||
|
||||
|
||||
func _get_name() -> String:
|
||||
return _plugin_name
|
||||
|
||||
|
||||
func _export_begin(features: PackedStringArray, is_debug: bool, path: String, flags: int) -> void:
|
||||
if _supports_platform(get_export_platform()):
|
||||
_export_path = path.simplify_path()
|
||||
_export_config = AdmobIosExportConfig.new()
|
||||
if not _export_config.export_config_file_exists() or _export_config.load_export_config_from_file() != OK:
|
||||
_export_config.load_export_config_from_node()
|
||||
|
||||
add_apple_embedded_platform_plist_content("<key>GADApplicationIdentifier</key>")
|
||||
add_apple_embedded_platform_plist_content(
|
||||
"\t<string>%s</string>" % (_export_config.real_application_id
|
||||
if _export_config.is_real else _export_config.debug_application_id ),
|
||||
)
|
||||
|
||||
if _export_config.att_enabled and _export_config.att_text and not _export_config.att_text.is_empty():
|
||||
add_apple_embedded_platform_plist_content("<key>NSUserTrackingUsageDescription</key>")
|
||||
add_apple_embedded_platform_plist_content("<string>%s</string>" % _export_config.att_text)
|
||||
|
||||
add_apple_embedded_platform_plist_content(
|
||||
MediationNetwork.generate_sk_ad_network_plist(
|
||||
_export_config.enabled_mediation_networks,
|
||||
),
|
||||
)
|
||||
|
||||
add_apple_embedded_platform_plist_content(NS_APP_TRANSPORT_SECURITY)
|
||||
|
||||
for __framework in IOS_FRAMEWORKS:
|
||||
add_apple_embedded_platform_framework(__framework)
|
||||
|
||||
for __framework in IOS_EMBEDDED_FRAMEWORKS:
|
||||
add_apple_embedded_platform_embedded_framework(__framework)
|
||||
|
||||
for __flag in IOS_LINKER_FLAGS:
|
||||
add_apple_embedded_platform_linker_flags(__flag)
|
||||
|
||||
for __bundle_file in IOS_BUNDLE_FILES:
|
||||
add_apple_embedded_platform_bundle_file(__bundle_file)
|
||||
|
||||
for __spm_dep in SPM_DEPENDENCIES:
|
||||
_spm_dependencies.append(SpmDependency.new(__spm_dep))
|
||||
|
||||
|
||||
func _end_generate_apple_embedded_project(path: String, will_build_archive: bool) -> void:
|
||||
GmpLogger.log_info("Apple export project generated at: %s. Will build archive: %s"
|
||||
% [path, str(will_build_archive)])
|
||||
|
||||
if _supports_platform(get_export_platform()):
|
||||
_spm_dependencies.append_array(_get_extra_dependencies())
|
||||
|
||||
if _spm_dependencies.is_empty():
|
||||
GmpLogger.log_info("No SPM dependencies to install. Skipping.")
|
||||
else:
|
||||
GmpLogger.log_info("Installing %d SPM dependencies." % _spm_dependencies.size())
|
||||
_install_dependencies(path.get_base_dir(), path.get_file().get_basename())
|
||||
|
||||
|
||||
func _get_extra_dependencies() -> Array[SpmDependency]:
|
||||
var __extra_dependencies:= [] as Array[SpmDependency]
|
||||
|
||||
__extra_dependencies.append_array(
|
||||
MediationNetwork.get_spm_dependencies(_export_config.enabled_mediation_networks))
|
||||
|
||||
return __extra_dependencies
|
||||
|
||||
|
||||
func _install_dependencies(a_base_dir: String, a_project_name: String) -> void:
|
||||
var __project_file_name:= "%s.xcodeproj" % a_project_name
|
||||
var __project_file_path:= a_base_dir.path_join(__project_file_name)
|
||||
if not DirAccess.dir_exists_absolute(__project_file_path):
|
||||
GmpLogger.log_error("Xcode project '%s' does not exist! Can't install SPM dependencies."
|
||||
% __project_file_path)
|
||||
return
|
||||
|
||||
var __script_name = "add_dependency.rb"
|
||||
var __add_dependency_script_path = a_base_dir.path_join(__script_name)
|
||||
var __result = _generate_add_dependency_script(__add_dependency_script_path)
|
||||
if __result != Error.OK:
|
||||
GmpLogger.log_error("Failed to generate '%s' script with error %d!" % [__script_name, __result])
|
||||
return
|
||||
|
||||
GmpLogger.log_info("Adding SPM dependencies to %s..." % __project_file_path)
|
||||
|
||||
for __spm_dep: SpmDependency in _spm_dependencies:
|
||||
for __spm_dep_product: String in __spm_dep.get_products():
|
||||
var exec_output: Array = []
|
||||
var exec_code = OS.execute("ruby", [
|
||||
__add_dependency_script_path,
|
||||
__project_file_path,
|
||||
__spm_dep.get_url(),
|
||||
__spm_dep.get_version(),
|
||||
__spm_dep_product,
|
||||
], exec_output, true, false)
|
||||
|
||||
if exec_code == 0:
|
||||
GmpLogger.log_info("Product %s for SPM dependency %s added successfully!"
|
||||
% [__spm_dep_product, __spm_dep.format_to_string()])
|
||||
for line in exec_output:
|
||||
GmpLogger.log_info("SPM: %s" % line)
|
||||
else:
|
||||
GmpLogger.log_info("Failed to add product %s for SPM dependency %s !"
|
||||
% [__spm_dep_product, __spm_dep.format_to_string()])
|
||||
for line in exec_output:
|
||||
GmpLogger.log_error("SPM: %s" % line)
|
||||
|
||||
GmpLogger.log_info("Resolving SPM dependencies...")
|
||||
|
||||
__script_name = "resolve_dependencies.sh"
|
||||
var __resolve_dependencies_script_path = a_base_dir.path_join(__script_name)
|
||||
__result = _generate_resolve_dependencies_script(__resolve_dependencies_script_path, a_base_dir, a_project_name)
|
||||
if __result != Error.OK:
|
||||
GmpLogger.log_error("Failed to generate '%s' script with error %d!" % [__script_name, __result])
|
||||
return
|
||||
|
||||
var exec_output: Array = []
|
||||
var exec_code = OS.execute(__resolve_dependencies_script_path, [], exec_output, true, false)
|
||||
|
||||
if exec_code == 0:
|
||||
for line in exec_output:
|
||||
GmpLogger.log_info("SPM: %s" % line)
|
||||
GmpLogger.log_info("Resolved dependencies successfully!")
|
||||
else:
|
||||
for line in exec_output:
|
||||
GmpLogger.log_error("SPM: %s" % line)
|
||||
GmpLogger.log_info("Failed to resolve dependencies! Try manually in Xcode.")
|
||||
|
||||
|
||||
const ADD_DEPENDENCY_RUBY_SCRIPT = """
|
||||
require 'xcodeproj'
|
||||
|
||||
project_path = ARGV[0]
|
||||
url = ARGV[1].strip
|
||||
version = ARGV[2].strip
|
||||
product_name = ARGV[3].strip
|
||||
|
||||
unless File.exist?(project_path)
|
||||
puts "Error: Xcode project not found at #{project_path}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
if url.empty? || version.empty? || product_name.empty?
|
||||
puts "Error: url, version, and product_name must all be non-empty."
|
||||
exit 1
|
||||
end
|
||||
|
||||
begin
|
||||
project = Xcodeproj::Project.open(project_path)
|
||||
target = project.targets.first
|
||||
|
||||
if target.nil?
|
||||
puts "Error: No targets found in the Xcode project."
|
||||
exit 1
|
||||
end
|
||||
|
||||
existing_dep = target.package_product_dependencies.find do |dep|
|
||||
dep.product_name == product_name
|
||||
end
|
||||
|
||||
if existing_dep
|
||||
puts "Warning: Product dependency '#{product_name}' already exists in the project. Skipping add.\n\n"
|
||||
else
|
||||
# Reuse an existing package reference for the same URL, or create a new one
|
||||
pkg = project.root_object.package_references.find do |p|
|
||||
p.repositoryURL == url
|
||||
end
|
||||
|
||||
if pkg
|
||||
puts "Reusing existing package reference for '#{url}'."
|
||||
else
|
||||
pkg = project.new(Xcodeproj::Project::Object::XCRemoteSwiftPackageReference)
|
||||
pkg.repositoryURL = url
|
||||
pkg.requirement = {
|
||||
'kind' => 'upToNextMajorVersion',
|
||||
'minimumVersion' => version
|
||||
}
|
||||
project.root_object.package_references << pkg
|
||||
end
|
||||
|
||||
# Create the product dependency and link it to the shared package reference
|
||||
ref = project.new(Xcodeproj::Project::Object::XCSwiftPackageProductDependency)
|
||||
ref.product_name = product_name
|
||||
ref.package = pkg
|
||||
target.package_product_dependencies << ref
|
||||
|
||||
puts "Successfully added SPM dependency '#{product_name}' " \
|
||||
"(#{url} @ #{version}) to #{File.basename(project_path)}\n\n"
|
||||
end
|
||||
|
||||
project.save
|
||||
|
||||
rescue => e
|
||||
puts "An error occurred: #{e.message}\n\n"
|
||||
exit 1
|
||||
end
|
||||
"""
|
||||
func _generate_add_dependency_script(a_script_path: String) -> Error:
|
||||
var __result = Error.OK
|
||||
|
||||
var __script_content = ADD_DEPENDENCY_RUBY_SCRIPT
|
||||
|
||||
__result = _create_script(a_script_path, __script_content)
|
||||
|
||||
return __result
|
||||
|
||||
|
||||
const RESOLVE_DEPENDENCIES_BASH_SCRIPT = """
|
||||
#!/bin/bash
|
||||
set -e # Exit on error
|
||||
|
||||
xcodebuild -resolvePackageDependencies \
|
||||
-project "%s.xcodeproj" \
|
||||
-scheme "%s"
|
||||
"""
|
||||
func _generate_resolve_dependencies_script(a_script_path: String, a_base_dir: String,
|
||||
a_project_name: String) -> Error:
|
||||
var __result: Error = Error.OK
|
||||
|
||||
var __script_content = RESOLVE_DEPENDENCIES_BASH_SCRIPT \
|
||||
% [ ProjectSettings.globalize_path(a_base_dir.path_join(a_project_name)), a_project_name ]
|
||||
|
||||
__result = _create_script(a_script_path, __script_content)
|
||||
|
||||
return __result
|
||||
|
||||
|
||||
func _create_script(a_script_path: String, a_script_content: String) -> Error:
|
||||
var __result: Error = Error.OK
|
||||
|
||||
var __script_file = FileAccess.open(a_script_path, FileAccess.WRITE)
|
||||
if __script_file:
|
||||
__script_file.store_string(a_script_content)
|
||||
__script_file.close()
|
||||
else:
|
||||
__result = Error.ERR_FILE_CANT_WRITE
|
||||
|
||||
var chmod_output: Array = []
|
||||
var chmod_code = OS.execute("chmod", ["+x", a_script_path], chmod_output, true, false)
|
||||
if chmod_code != 0:
|
||||
GmpLogger.log_error("Failed to chmod %s script: %s"
|
||||
% [a_script_path, (chmod_output if chmod_output.size() > 0 else "Unknown error")])
|
||||
__result = Error.ERR_FILE_NO_PERMISSION
|
||||
|
||||
return __result
|
||||
@@ -0,0 +1 @@
|
||||
uid://ombwj85k4mvh
|
||||
@@ -0,0 +1,350 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
|
||||
@icon("icon.png")
|
||||
class_name InlineAdaptiveBanner
|
||||
extends Control
|
||||
|
||||
const MINIMUM_VALID_WIDTH: float = 50.0
|
||||
const MINIMUM_CLIP_THRESHOLD: float = 0.05
|
||||
const MAXIMUM_CLIP_THRESHOLD: float = 1.0
|
||||
const MINIMUM_RESIZE_THRESHOLD: float = 10.0
|
||||
const MAXIMUM_RESIZE_THRESHOLD: float = 500.0
|
||||
|
||||
## Maximum pixel height of the requested ad. If set to -1, height will be automatically determined.
|
||||
@export var max_ad_height: int = -1
|
||||
|
||||
## Minimum portion of the banner’s area (as a percentage) that must be clipped before the banner is considered hidden.
|
||||
@export_range(MINIMUM_CLIP_THRESHOLD, MAXIMUM_CLIP_THRESHOLD, 0.05) var clip_threshold: float = 0.1:
|
||||
set(a_value):
|
||||
clip_threshold = clampf(a_value, MINIMUM_CLIP_THRESHOLD, MAXIMUM_CLIP_THRESHOLD)
|
||||
|
||||
## Width-change threshold (in pixels) that triggers a banner reload when the ad container is resized by this amount
|
||||
## or more.
|
||||
@export_range(MINIMUM_RESIZE_THRESHOLD, MAXIMUM_RESIZE_THRESHOLD, 5.0) var resize_threshold: float = 50.0:
|
||||
set(a_value):
|
||||
resize_threshold = clampf(a_value, MINIMUM_RESIZE_THRESHOLD, MAXIMUM_RESIZE_THRESHOLD)
|
||||
|
||||
## Path to Admob node.
|
||||
@export_node_path("Admob") var admob_path: NodePath
|
||||
|
||||
## Whether the inline-adaptive banner ad should be automatically loaded.
|
||||
@export var auto_load: bool = false
|
||||
|
||||
@export_category("Ad Unit Identifier")
|
||||
## Use ad unit identifier value defined in the Admob node.
|
||||
@export var inherit_from_admob_node: bool = false
|
||||
|
||||
@export_group("Android", "ad_unit_id_android")
|
||||
## Ad unit ID of the inline adaptive banner ad to be loaded on the Android platform.
|
||||
@export var ad_unit_id_android_debug: String = "ca-app-pub-3940256099942544/2014213617"
|
||||
|
||||
## Ad unit ID of the inline adaptive banner ad to be loaded on the Android platform.
|
||||
@export var ad_unit_id_android_real: String = ""
|
||||
|
||||
@export_group("iOS", "ad_unit_id_ios")
|
||||
## Ad unit ID of the inline adaptive banner ad to be loaded on the iOS platform.
|
||||
@export var ad_unit_id_ios_debug: String = "ca-app-pub-3940256099942544/2934735716"
|
||||
|
||||
## Ad unit ID of the inline adaptive banner ad to be loaded on the iOS platform.
|
||||
@export var ad_unit_id_ios_real: String = ""
|
||||
|
||||
var admob: Admob
|
||||
var _ad_unit_id: String
|
||||
var ad_id: String = ""
|
||||
|
||||
var last_width: float
|
||||
|
||||
var pending_load: bool = false
|
||||
var is_position_dirty: bool = false
|
||||
var is_banner_visible: bool = false
|
||||
var do_ignore_resize: bool = false
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
var __admob: Admob
|
||||
if admob_path:
|
||||
__admob = get_node(admob_path)
|
||||
|
||||
if __admob:
|
||||
initialize(__admob)
|
||||
else:
|
||||
push_warning("Admob node not found")
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if ad_id != "":
|
||||
remove_ad()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
|
||||
size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
|
||||
last_width = size.x
|
||||
resized.connect(_on_resized)
|
||||
|
||||
|
||||
func initialize(a_admob_node: Admob) -> void:
|
||||
admob = a_admob_node
|
||||
|
||||
visibility_changed.connect(_on_visibility_changed)
|
||||
admob.banner_ad_loaded.connect(_on_banner_ad_loaded)
|
||||
admob.banner_ad_failed_to_load.connect(_on_banner_ad_failed_to_load)
|
||||
admob.banner_ad_refreshed.connect(_on_banner_ad_refreshed)
|
||||
admob.banner_ad_size_measured.connect(_on_banner_ad_size_measured)
|
||||
|
||||
if admob.is_initialization_completed:
|
||||
if auto_load:
|
||||
call_deferred("_deferred_load_ad")
|
||||
else:
|
||||
admob.initialization_completed.connect(_on_Admob_initialization_completed)
|
||||
|
||||
if inherit_from_admob_node:
|
||||
_ad_unit_id = admob._banner_id
|
||||
else:
|
||||
if OS.has_feature("ios"):
|
||||
if admob.is_real:
|
||||
_ad_unit_id = ad_unit_id_ios_real
|
||||
else:
|
||||
_ad_unit_id = ad_unit_id_ios_debug
|
||||
else:
|
||||
if admob.is_real:
|
||||
_ad_unit_id = ad_unit_id_android_real
|
||||
else:
|
||||
_ad_unit_id = ad_unit_id_android_debug
|
||||
|
||||
|
||||
func _deferred_load_ad() -> void:
|
||||
load_ad()
|
||||
_mark_position_dirty()
|
||||
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# AD MANIPULATION
|
||||
# --------------------------------------------------------------
|
||||
|
||||
|
||||
func load_ad() -> void:
|
||||
if _ad_unit_id == "":
|
||||
return
|
||||
|
||||
var req := LoadAdRequest.new()
|
||||
req.set_ad_unit_id(_ad_unit_id)
|
||||
req.set_ad_size(LoadAdRequest.RequestedAdSize.INLINE_ADAPTIVE)
|
||||
|
||||
if custom_minimum_size.x < MINIMUM_VALID_WIDTH:
|
||||
GmpLogger.log_warn("Can't load ad. Invalid inline adaptive banner width: %.1f" % custom_minimum_size.x)
|
||||
return
|
||||
|
||||
req.set_adaptive_width(round(custom_minimum_size.x))
|
||||
|
||||
if max_ad_height != -1:
|
||||
req.set_adaptive_max_height(max_ad_height)
|
||||
|
||||
req.set_ad_position(LoadAdRequest.AdPosition.CUSTOM)
|
||||
|
||||
pending_load = true
|
||||
admob.load_banner_ad(req)
|
||||
|
||||
|
||||
func show_ad() -> void:
|
||||
if not is_banner_visible:
|
||||
admob.show_banner_ad(ad_id)
|
||||
is_banner_visible = true
|
||||
|
||||
|
||||
func hide_ad() -> void:
|
||||
if is_banner_visible:
|
||||
admob.hide_banner_ad(ad_id)
|
||||
is_banner_visible = false
|
||||
|
||||
|
||||
func move_ad(a_physical_position: Vector2) -> void:
|
||||
admob.move_banner_ad(ad_id, a_physical_position.x, a_physical_position.y)
|
||||
|
||||
|
||||
func remove_ad() -> void:
|
||||
admob.remove_banner_ad(ad_id)
|
||||
is_banner_visible = false
|
||||
ad_id = ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# POSITIONING
|
||||
# --------------------------------------------------------------
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if is_position_dirty:
|
||||
_deferred_update_position()
|
||||
is_position_dirty = false
|
||||
else:
|
||||
update_banner_position()
|
||||
|
||||
|
||||
func _mark_position_dirty() -> void:
|
||||
is_position_dirty = true
|
||||
call_deferred("_deferred_update_position")
|
||||
|
||||
|
||||
func _deferred_update_position() -> void:
|
||||
update_banner_position()
|
||||
|
||||
|
||||
func update_banner_position() -> void:
|
||||
if ad_id == "":
|
||||
return
|
||||
|
||||
# Check basic tree visibility (Tabs, hidden parents)
|
||||
if not is_visible_in_tree():
|
||||
hide_ad()
|
||||
return
|
||||
|
||||
# Smart clipping (visibility logic)
|
||||
var is_clipped_out: bool = false
|
||||
|
||||
if size.x > 1.0 and size.y > 1.0:
|
||||
var virtual_rect := get_global_rect()
|
||||
var original_area := virtual_rect.get_area()
|
||||
var visible_region := virtual_rect
|
||||
var parent = get_parent()
|
||||
|
||||
# Walk up the tree to find ScrollContainers or other clippers
|
||||
while parent is Control:
|
||||
if parent.clip_contents:
|
||||
var parent_rect: Rect2 = parent.get_global_rect()
|
||||
var intersection := visible_region.intersection(parent_rect)
|
||||
|
||||
# No intersection -> fully clipped (100%)
|
||||
if intersection.get_area() <= 0:
|
||||
is_clipped_out = true
|
||||
break
|
||||
|
||||
visible_region = intersection
|
||||
parent = parent.get_parent()
|
||||
|
||||
# Final check
|
||||
if not get_viewport_rect().intersects(virtual_rect):
|
||||
is_clipped_out = true
|
||||
else:
|
||||
# Calculate the percentage clipped
|
||||
var visible_area := visible_region.get_area()
|
||||
var clipped_ratio := 1.0 - (visible_area / original_area)
|
||||
|
||||
if clipped_ratio >= clip_threshold:
|
||||
is_clipped_out = true
|
||||
|
||||
if is_clipped_out:
|
||||
if is_banner_visible:
|
||||
hide_ad()
|
||||
else:
|
||||
if not is_banner_visible:
|
||||
show_ad()
|
||||
|
||||
move_ad(_godot_to_physical(global_position))
|
||||
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# SIGNAL HANDLERS
|
||||
# --------------------------------------------------------------
|
||||
|
||||
|
||||
func _on_Admob_initialization_completed(_status: InitializationStatus) -> void:
|
||||
if auto_load:
|
||||
call_deferred("_deferred_load_ad")
|
||||
|
||||
|
||||
func _on_resized() -> void:
|
||||
if ad_id == "":
|
||||
return
|
||||
|
||||
if do_ignore_resize:
|
||||
do_ignore_resize = false
|
||||
return
|
||||
|
||||
if abs(size.x - last_width) < resize_threshold:
|
||||
return
|
||||
|
||||
last_width = size.x
|
||||
remove_ad()
|
||||
call_deferred("_deferred_load_ad")
|
||||
|
||||
_mark_position_dirty()
|
||||
|
||||
|
||||
func _on_visibility_changed() -> void:
|
||||
if ad_id != "":
|
||||
if is_visible_in_tree():
|
||||
show_ad()
|
||||
else:
|
||||
hide_ad()
|
||||
|
||||
|
||||
func _on_banner_ad_loaded(loaded_ad_info: AdInfo, _response: ResponseInfo) -> void:
|
||||
if !pending_load:
|
||||
return
|
||||
|
||||
if loaded_ad_info.get_load_ad_request().get_ad_size() != LoadAdRequest.RequestedAdSize.INLINE_ADAPTIVE:
|
||||
return
|
||||
|
||||
if loaded_ad_info.get_load_ad_request().get_ad_unit_id().casecmp_to(_ad_unit_id) != 0:
|
||||
return
|
||||
|
||||
pending_load = false
|
||||
ad_id = loaded_ad_info.get_ad_id()
|
||||
|
||||
_apply_loaded_size(Vector2(loaded_ad_info.get_measured_width(), loaded_ad_info.get_measured_height()))
|
||||
_mark_position_dirty()
|
||||
|
||||
|
||||
func _on_banner_ad_failed_to_load(failed_ad_info: AdInfo, _err: LoadAdError) -> void:
|
||||
if failed_ad_info.get_load_ad_request().get_ad_size() != LoadAdRequest.RequestedAdSize.INLINE_ADAPTIVE:
|
||||
return
|
||||
|
||||
if failed_ad_info.get_load_ad_request().get_ad_unit_id().casecmp_to(_ad_unit_id) != 0:
|
||||
return
|
||||
|
||||
if pending_load:
|
||||
pending_load = false
|
||||
|
||||
|
||||
func _on_banner_ad_size_measured(ad_info: AdInfo) -> void:
|
||||
if ad_info.get_ad_id().casecmp_to(ad_id) == 0:
|
||||
_apply_loaded_size(Vector2(ad_info.get_measured_width(), ad_info.get_measured_height()))
|
||||
|
||||
|
||||
func _on_banner_ad_refreshed(refreshed_ad_info: AdInfo, _response: ResponseInfo) -> void:
|
||||
if refreshed_ad_info.get_ad_id().casecmp_to(ad_id) == 0:
|
||||
hide_ad() # Prevent ad from briefly displaying out of position
|
||||
_apply_loaded_size(Vector2(refreshed_ad_info.get_measured_width(), refreshed_ad_info.get_measured_height()))
|
||||
_mark_position_dirty()
|
||||
|
||||
|
||||
# --------------------------------------------------------------
|
||||
# UTILITY
|
||||
# --------------------------------------------------------------
|
||||
|
||||
|
||||
func _apply_loaded_size(a_size: Vector2) -> void:
|
||||
do_ignore_resize = true
|
||||
|
||||
custom_minimum_size = a_size
|
||||
size = a_size
|
||||
|
||||
|
||||
func _calculate_scale_factor() -> Vector2:
|
||||
var screen_size: Vector2 = DisplayServer.window_get_size() as Vector2
|
||||
var virtual_size: Vector2 = get_viewport().get_visible_rect().size
|
||||
return screen_size / virtual_size
|
||||
|
||||
|
||||
func _physical_to_godot(a_physical_size: Vector2) -> Vector2:
|
||||
return a_physical_size / _calculate_scale_factor()
|
||||
|
||||
|
||||
func _godot_to_physical(a_godot_size: Vector2) -> Vector2:
|
||||
return a_godot_size * _calculate_scale_factor()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dq4pb7uer2kjh
|
||||
@@ -0,0 +1,8 @@
|
||||
[General]
|
||||
is_real=false
|
||||
|
||||
[Debug]
|
||||
app_id="ca-app-pub-3940256099942544~3347511713"
|
||||
|
||||
[Release]
|
||||
app_id=""
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
class_name AdCache
|
||||
extends RefCounted
|
||||
|
||||
var limit: int
|
||||
var label: String
|
||||
var remove_ad_callable: Callable
|
||||
|
||||
var _ads: Dictionary[String, Object]
|
||||
var _ad_queue: Array[String]
|
||||
|
||||
|
||||
func _init(a_limit: int, a_label: String, a_callable: Callable) -> void:
|
||||
self.limit = a_limit
|
||||
self.label = a_label
|
||||
self.remove_ad_callable = a_callable
|
||||
|
||||
_ads = {}
|
||||
_ad_queue = []
|
||||
|
||||
|
||||
func is_empty() -> bool:
|
||||
return _ads.is_empty()
|
||||
|
||||
|
||||
func has_key(a_key: String) -> bool:
|
||||
return _ads.has(a_key)
|
||||
|
||||
|
||||
func last_key() -> String:
|
||||
return "" if _ad_queue.is_empty() else _ad_queue[0]
|
||||
|
||||
|
||||
func all_keys() -> Array[String]:
|
||||
return _ads.keys()
|
||||
|
||||
|
||||
func cache(a_ad_id: String, a_ad_data: Object) -> void:
|
||||
_ads[a_ad_id] = a_ad_data
|
||||
_ad_queue.push_front(a_ad_id)
|
||||
while _ads.size() > limit:
|
||||
(
|
||||
GmpLogger
|
||||
. log_warn(
|
||||
(
|
||||
"%s: %s cache size (%d) has exceeded maximum (%d)"
|
||||
% [
|
||||
Admob.PLUGIN_SINGLETON_NAME,
|
||||
label,
|
||||
_ads.size(),
|
||||
limit,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
remove_ad_callable.call(_erase_oldest())
|
||||
|
||||
|
||||
func erase(a_ad_id: String) -> void:
|
||||
_ad_queue.erase(a_ad_id)
|
||||
_ads.erase(a_ad_id)
|
||||
|
||||
|
||||
func erase_last() -> String:
|
||||
var __removed_ad_id: String = _ad_queue.pop_front()
|
||||
_ads.erase(__removed_ad_id)
|
||||
return __removed_ad_id
|
||||
|
||||
|
||||
func _erase_oldest() -> String:
|
||||
var __removed_ad_id: String = _ad_queue.pop_back()
|
||||
_ads.erase(__removed_ad_id)
|
||||
return __removed_ad_id
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8k2i5b2rg7qx
|
||||
@@ -0,0 +1,24 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name AdmobAndroidExportConfig
|
||||
extends AdmobExportConfig
|
||||
|
||||
const ANDROID_CONFIG_FILE_PATH: String = "res://addons/" + PLUGIN_NAME + "/android_export.cfg"
|
||||
|
||||
|
||||
func get_config_file_path() -> String:
|
||||
return ANDROID_CONFIG_FILE_PATH
|
||||
|
||||
|
||||
func load_platform_specific_export_config_from_node(a_node: Admob) -> Error:
|
||||
debug_application_id = a_node.android_debug_application_id
|
||||
real_application_id = a_node.android_real_application_id
|
||||
|
||||
return Error.OK
|
||||
|
||||
|
||||
func print_loaded_config() -> void:
|
||||
super.print_loaded_config()
|
||||
GmpLogger.log_info("... debug_application_id: %s" % debug_application_id)
|
||||
GmpLogger.log_info("... real_application_id: %s" % real_application_id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd7jn56dvb8v4
|
||||
@@ -0,0 +1,190 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name AdmobExportConfig
|
||||
extends RefCounted
|
||||
|
||||
const PLUGIN_NODE_TYPE_NAME = "Admob"
|
||||
const PLUGIN_NAME: String = "AdmobPlugin"
|
||||
|
||||
const CONFIG_FILE_SECTION_GENERAL: String = "General"
|
||||
const CONFIG_FILE_SECTION_DEBUG: String = "Debug"
|
||||
const CONFIG_FILE_SECTION_RELEASE: String = "Release"
|
||||
const CONFIG_FILE_SECTION_MEDIATION: String = "Mediation"
|
||||
|
||||
const CONFIG_FILE_KEY_IS_REAL: String = "is_real"
|
||||
const CONFIG_FILE_KEY_APP_ID: String = "app_id"
|
||||
const CONFIG_FILE_KEY_ENABLED_NETWORKS: String = "enabled_networks"
|
||||
|
||||
var is_real: bool
|
||||
var debug_application_id: String
|
||||
var real_application_id: String
|
||||
var enabled_mediation_networks: Array[MediationNetwork] = []
|
||||
|
||||
|
||||
func get_config_file_path() -> String:
|
||||
return ""
|
||||
|
||||
|
||||
func export_config_file_exists() -> bool:
|
||||
return FileAccess.file_exists(get_config_file_path())
|
||||
|
||||
|
||||
func load_export_config_from_file() -> Error:
|
||||
GmpLogger.log_info("Loading export config from file!")
|
||||
|
||||
var __result = Error.OK
|
||||
|
||||
var __config_file_path = get_config_file_path()
|
||||
var __config_file = ConfigFile.new()
|
||||
|
||||
var __load_result = __config_file.load(__config_file_path)
|
||||
if __load_result == Error.OK:
|
||||
is_real = __config_file.get_value(CONFIG_FILE_SECTION_GENERAL, CONFIG_FILE_KEY_IS_REAL)
|
||||
debug_application_id = __config_file.get_value(CONFIG_FILE_SECTION_DEBUG, CONFIG_FILE_KEY_APP_ID)
|
||||
real_application_id = __config_file.get_value(CONFIG_FILE_SECTION_RELEASE, CONFIG_FILE_KEY_APP_ID)
|
||||
|
||||
if __config_file.has_section(CONFIG_FILE_SECTION_MEDIATION):
|
||||
if __config_file.has_section_key(CONFIG_FILE_SECTION_MEDIATION, CONFIG_FILE_KEY_ENABLED_NETWORKS):
|
||||
var __network_array := (
|
||||
Array(
|
||||
__config_file.get_value(CONFIG_FILE_SECTION_MEDIATION, CONFIG_FILE_KEY_ENABLED_NETWORKS, []),
|
||||
TYPE_STRING,
|
||||
&"",
|
||||
null,
|
||||
)
|
||||
as Array[String]
|
||||
)
|
||||
|
||||
for __network in __network_array:
|
||||
if MediationNetwork.is_valid_tag(__network):
|
||||
enabled_mediation_networks.append(MediationNetwork.get_by_tag(__network))
|
||||
else:
|
||||
GmpLogger.log_error("Invalid network tag '%s' in file %s!" % [__network, __config_file_path])
|
||||
else:
|
||||
(
|
||||
GmpLogger
|
||||
. log_error(
|
||||
(
|
||||
"Missing key %s in section %s of %s!"
|
||||
% [CONFIG_FILE_KEY_ENABLED_NETWORKS, CONFIG_FILE_SECTION_MEDIATION, __config_file_path]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if is_real == null or debug_application_id == null or real_application_id == null:
|
||||
__result = Error.ERR_INVALID_DATA
|
||||
else:
|
||||
__result = load_platform_specific_export_config_from_file(__config_file)
|
||||
|
||||
if __result != Error.OK:
|
||||
GmpLogger.log_error("Invalid export config file %s!" % __config_file_path)
|
||||
else:
|
||||
__result = Error.ERR_CANT_OPEN
|
||||
GmpLogger.log_error("Failed to open export config file %s!" % __config_file_path)
|
||||
|
||||
if __result == Error.OK:
|
||||
print_loaded_config()
|
||||
|
||||
return __result
|
||||
|
||||
|
||||
func load_platform_specific_export_config_from_file(a_config_file: ConfigFile) -> Error:
|
||||
return Error.OK
|
||||
|
||||
|
||||
func load_export_config_from_node() -> Error:
|
||||
GmpLogger.log_info("Loading export config from node!")
|
||||
|
||||
var __result = Error.OK
|
||||
|
||||
var __admob_node: Admob
|
||||
|
||||
GmpLogger.log_info("Searching edited scene for %s node..." % PLUGIN_NODE_TYPE_NAME)
|
||||
var __edited_scene_root: Node = EditorInterface.get_edited_scene_root()
|
||||
if __edited_scene_root:
|
||||
__admob_node = get_plugin_node(__edited_scene_root)
|
||||
else:
|
||||
GmpLogger.log_info("No edited scene found")
|
||||
|
||||
if not __admob_node:
|
||||
GmpLogger.log_info("Searching main scene for %s node..." % PLUGIN_NODE_TYPE_NAME)
|
||||
var __main_scene_path: String = ProjectSettings.get_setting("application/run/main_scene")
|
||||
if __main_scene_path and __main_scene_path != "":
|
||||
var __packed_main: PackedScene = load(__main_scene_path)
|
||||
if __packed_main:
|
||||
var __main_scene = __packed_main.instantiate()
|
||||
__admob_node = get_plugin_node(__main_scene)
|
||||
else:
|
||||
GmpLogger.log_info("Main scene path not defined in the project settings")
|
||||
|
||||
if not __admob_node:
|
||||
GmpLogger.log_info("Searching all project scenes for %s node..." % PLUGIN_NODE_TYPE_NAME)
|
||||
|
||||
var collect_scene_paths: Callable = func(
|
||||
a_dir_path: String,
|
||||
a_collected_paths: Array[String],
|
||||
a_recursive_func: Callable,
|
||||
) -> void:
|
||||
for __file in DirAccess.get_files_at(a_dir_path):
|
||||
if __file.ends_with(".tscn") or __file.ends_with(".scn"):
|
||||
a_collected_paths.append(a_dir_path.path_join(__file))
|
||||
|
||||
for __dir in DirAccess.get_directories_at(a_dir_path):
|
||||
a_recursive_func.call(a_dir_path.path_join(__dir), a_collected_paths, a_recursive_func)
|
||||
|
||||
var __scene_paths: Array[String] = []
|
||||
collect_scene_paths.call("res://", __scene_paths, collect_scene_paths)
|
||||
|
||||
for __scene_path in __scene_paths:
|
||||
var packed: PackedScene = load(__scene_path)
|
||||
if packed:
|
||||
var __instance = packed.instantiate()
|
||||
var __found_node: Admob = get_plugin_node(__instance)
|
||||
if __found_node:
|
||||
GmpLogger.log_info("Found %s node in scene: %s" % [PLUGIN_NODE_TYPE_NAME, __scene_path])
|
||||
__admob_node = __found_node
|
||||
break
|
||||
|
||||
# Load configuration if node found
|
||||
if __admob_node:
|
||||
is_real = __admob_node.is_real
|
||||
enabled_mediation_networks = MediationNetwork.get_all_enabled(__admob_node.enabled_networks)
|
||||
|
||||
__result = load_platform_specific_export_config_from_node(__admob_node)
|
||||
if __result == Error.OK:
|
||||
print_loaded_config()
|
||||
else:
|
||||
GmpLogger.log_error("Invalid %s node for %s!" % [PLUGIN_NODE_TYPE_NAME, PLUGIN_NAME])
|
||||
else:
|
||||
GmpLogger.log_error("%s failed to find %s node!" % [PLUGIN_NAME, PLUGIN_NODE_TYPE_NAME])
|
||||
__result = Error.ERR_UNCONFIGURED
|
||||
|
||||
return __result
|
||||
|
||||
|
||||
func load_platform_specific_export_config_from_node(a_node: Admob) -> Error:
|
||||
return Error.OK
|
||||
|
||||
|
||||
func print_loaded_config() -> void:
|
||||
GmpLogger.log_info("Loaded export configuration settings:")
|
||||
GmpLogger.log_info(" is_real: %s" % ("true" if is_real else "false"))
|
||||
GmpLogger.log_info(
|
||||
" enabled_mediation_networks: %s" % MediationNetwork.generate_tag_list(enabled_mediation_networks)
|
||||
)
|
||||
|
||||
|
||||
func get_plugin_node(a_node: Node) -> Admob:
|
||||
var __result: Admob
|
||||
|
||||
if a_node is Admob:
|
||||
__result = a_node
|
||||
elif a_node.get_child_count() > 0:
|
||||
for __child in a_node.get_children():
|
||||
var __child_result = get_plugin_node(__child)
|
||||
if __child_result is Admob:
|
||||
__result = __child_result
|
||||
break
|
||||
|
||||
return __result
|
||||
@@ -0,0 +1 @@
|
||||
uid://byw0f6m12b2s2
|
||||
@@ -0,0 +1,43 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name AdmobIosExportConfig
|
||||
extends AdmobExportConfig
|
||||
|
||||
const IOS_CONFIG_FILE_PATH: String = "res://addons/" + PLUGIN_NAME + "/ios_export.cfg"
|
||||
|
||||
const CONFIG_FILE_SECTION_ATT: String = "ATT"
|
||||
|
||||
const CONFIG_FILE_KEY_ATT_ENABLED: String = "att_enabled"
|
||||
const CONFIG_FILE_KEY_ATT_TEXT: String = "att_text"
|
||||
|
||||
var att_enabled: bool
|
||||
var att_text: String
|
||||
|
||||
|
||||
func get_config_file_path() -> String:
|
||||
return IOS_CONFIG_FILE_PATH
|
||||
|
||||
|
||||
func load_platform_specific_export_config_from_file(a_config_file: ConfigFile) -> Error:
|
||||
att_enabled = a_config_file.get_value(CONFIG_FILE_SECTION_ATT, CONFIG_FILE_KEY_ATT_ENABLED, false)
|
||||
att_text = a_config_file.get_value(CONFIG_FILE_SECTION_ATT, CONFIG_FILE_KEY_ATT_TEXT)
|
||||
|
||||
return Error.OK
|
||||
|
||||
|
||||
func load_platform_specific_export_config_from_node(a_node: Admob) -> Error:
|
||||
debug_application_id = a_node.ios_debug_application_id
|
||||
real_application_id = a_node.ios_real_application_id
|
||||
att_enabled = a_node.att_enabled
|
||||
att_text = a_node.att_text
|
||||
|
||||
return Error.OK
|
||||
|
||||
|
||||
func print_loaded_config() -> void:
|
||||
super.print_loaded_config()
|
||||
GmpLogger.log_info("... debug_application_id: %s" % debug_application_id)
|
||||
GmpLogger.log_info("... real_application_id: %s" % real_application_id)
|
||||
GmpLogger.log_info("... att_enabled: %s" % ("true" if att_enabled else "false"))
|
||||
GmpLogger.log_info("... att_text: %s" % att_text)
|
||||
@@ -0,0 +1 @@
|
||||
uid://hamb6dlvl4bc
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cmkpgeo3anvuw"
|
||||
path="res://.godot/imported/icon.png-928f79d906c5a286de384358dfb1ba26.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/AdmobPlugin/icon.png"
|
||||
dest_files=["res://.godot/imported/icon.png-928f79d906c5a286de384358dfb1ba26.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -0,0 +1,12 @@
|
||||
[General]
|
||||
is_real=false
|
||||
|
||||
[Debug]
|
||||
app_id="ca-app-pub-3940256099942544~1458002511"
|
||||
|
||||
[Release]
|
||||
app_id=""
|
||||
|
||||
[ATT]
|
||||
att_enabled=false
|
||||
att_text="Deine Erlaubnis hilft uns, Werbung zu messen und das Spiel zu verbessern."
|
||||
@@ -0,0 +1,32 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name AdError
|
||||
extends RefCounted
|
||||
|
||||
const CODE_PROPERTY := &"code"
|
||||
const DOMAIN_PROPERTY := &"domain"
|
||||
const MESSAGE_PROPERTY := &"message"
|
||||
const CAUSE_PROPERTY := &"cause"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary):
|
||||
_data = a_data
|
||||
|
||||
|
||||
func get_code() -> int:
|
||||
return _data[CODE_PROPERTY]
|
||||
|
||||
|
||||
func get_domain() -> String:
|
||||
return _data[DOMAIN_PROPERTY]
|
||||
|
||||
|
||||
func get_message() -> String:
|
||||
return _data[MESSAGE_PROPERTY]
|
||||
|
||||
|
||||
func get_cause() -> AdError:
|
||||
return AdError.new(_data[CAUSE_PROPERTY]) if _data.has(CAUSE_PROPERTY) else null
|
||||
@@ -0,0 +1 @@
|
||||
uid://0att8tjqjm8t
|
||||
@@ -0,0 +1,37 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name AdInfo
|
||||
extends RefCounted
|
||||
|
||||
const AD_ID_PROPERTY := &"ad_id"
|
||||
const MEASURED_WIDTH_PROPERTY := &"measured_width"
|
||||
const MEASURED_HEIGHT_PROPERTY := &"measured_height"
|
||||
const IS_COLLAPSIBLE_PROPERTY := &"is_collapsible"
|
||||
const LOAD_AD_REQUEST_PROPERTY := &"load_ad_request"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary):
|
||||
_data = a_data
|
||||
|
||||
|
||||
func get_ad_id() -> String:
|
||||
return _data[AD_ID_PROPERTY] if _data.has(AD_ID_PROPERTY) else ""
|
||||
|
||||
|
||||
func get_measured_width() -> int:
|
||||
return _data[MEASURED_WIDTH_PROPERTY] if _data.has(MEASURED_WIDTH_PROPERTY) else 0
|
||||
|
||||
|
||||
func get_measured_height() -> int:
|
||||
return _data[MEASURED_HEIGHT_PROPERTY] if _data.has(MEASURED_HEIGHT_PROPERTY) else 0
|
||||
|
||||
|
||||
func get_is_collapsible() -> bool:
|
||||
return _data[IS_COLLAPSIBLE_PROPERTY] if _data.has(IS_COLLAPSIBLE_PROPERTY) else false
|
||||
|
||||
|
||||
func get_load_ad_request() -> LoadAdRequest:
|
||||
return LoadAdRequest.new(_data[LOAD_AD_REQUEST_PROPERTY]) if _data.has(LOAD_AD_REQUEST_PROPERTY) else null
|
||||
@@ -0,0 +1 @@
|
||||
uid://pm7kx5541j4
|
||||
@@ -0,0 +1,22 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name AdSize
|
||||
extends RefCounted
|
||||
|
||||
const WIDTH_PROPERTY := &"width"
|
||||
const HEIGHT_PROPERTY := &"height"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary):
|
||||
_data = a_data if a_data else {}
|
||||
|
||||
|
||||
func get_width() -> int:
|
||||
return _data[WIDTH_PROPERTY] if _data.has(WIDTH_PROPERTY) else 0
|
||||
|
||||
|
||||
func get_height() -> int:
|
||||
return _data[HEIGHT_PROPERTY] if _data.has(HEIGHT_PROPERTY) else 0
|
||||
@@ -0,0 +1 @@
|
||||
uid://1y453yingj1w
|
||||
@@ -0,0 +1,52 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name AdapterResponseInfo
|
||||
extends RefCounted
|
||||
|
||||
const AD_ERROR_PROPERTY := &"ad_error"
|
||||
const AD_SOURCE_ID_PROPERTY := &"ad_source_id"
|
||||
const AD_SOURCE_INSTANCE_ID_PROPERTY := &"ad_source_instance_id"
|
||||
const AD_SOURCE_INSTANCE_NAME_PROPERTY := &"ad_source_instance_name"
|
||||
const AD_SOURCE_NAME_PROPERTY := &"ad_source_name"
|
||||
const ADAPTER_CLASS_NAME_PROPERTY := &"adapter_class_name"
|
||||
const NETWORK_TAG_PROPERTY := &"network_tag"
|
||||
const LATENCY_PROPERTY := &"latency"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary):
|
||||
_data = a_data
|
||||
|
||||
|
||||
func get_ad_error() -> AdError:
|
||||
return AdError.new(_data[AD_ERROR_PROPERTY]) if _data.has(AD_ERROR_PROPERTY) else null
|
||||
|
||||
|
||||
func get_ad_source_id() -> String:
|
||||
return _data[AD_SOURCE_ID_PROPERTY]
|
||||
|
||||
|
||||
func get_ad_source_instance_id() -> String:
|
||||
return _data[AD_SOURCE_INSTANCE_ID_PROPERTY]
|
||||
|
||||
|
||||
func get_ad_source_instance_name() -> String:
|
||||
return _data[AD_SOURCE_INSTANCE_NAME_PROPERTY]
|
||||
|
||||
|
||||
func get_ad_source_name() -> String:
|
||||
return _data[AD_SOURCE_NAME_PROPERTY]
|
||||
|
||||
|
||||
func get_adapter_class_name() -> String:
|
||||
return _data[ADAPTER_CLASS_NAME_PROPERTY]
|
||||
|
||||
|
||||
func get_network_tag() -> String:
|
||||
return _data[NETWORK_TAG_PROPERTY]
|
||||
|
||||
|
||||
func get_latency() -> int:
|
||||
return _data[LATENCY_PROPERTY]
|
||||
@@ -0,0 +1 @@
|
||||
uid://kwsy2dkniyn8
|
||||
@@ -0,0 +1,32 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name AdapterStatus
|
||||
extends RefCounted
|
||||
|
||||
const ADAPTER_CLASS_PROPERTY := &"adapter_class"
|
||||
const LATENCY_PROPERTY := &"latency"
|
||||
const INITIALIZATION_STATE_PROPERTY := &"initialization_state"
|
||||
const DESCRIPTION_PROPERTY := &"description"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary):
|
||||
_data = a_data
|
||||
|
||||
|
||||
func get_adapter_class() -> String:
|
||||
return _data[ADAPTER_CLASS_PROPERTY]
|
||||
|
||||
|
||||
func get_latency() -> int:
|
||||
return _data[LATENCY_PROPERTY]
|
||||
|
||||
|
||||
func get_initialization_state() -> String:
|
||||
return _data[INITIALIZATION_STATE_PROPERTY]
|
||||
|
||||
|
||||
func get_description() -> String:
|
||||
return _data[DESCRIPTION_PROPERTY]
|
||||
@@ -0,0 +1 @@
|
||||
uid://scnfdeiyquod
|
||||
@@ -0,0 +1,105 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name AdmobConfig
|
||||
extends RefCounted
|
||||
|
||||
enum ContentRating {
|
||||
G, ## G means "General Audiences" and is suitable for all ages, containing no objectionable material
|
||||
## (minimal violence, no nudity or sex scenes, and no drug use).
|
||||
PG, ## PG stands for "Parental Guidance Suggested," meaning that some material may not be suitable for young
|
||||
## children (may include themes, language, or mild violence).
|
||||
T, ## T means Teen, and indicates content suitable for ages 13 and older (can include violence, suggestive themes,
|
||||
## crude humor, simulated gambling, and/or infrequent strong language).
|
||||
MA, ## MA stands for "Mature Audience", signifying that the material is intended for adults (may include graphic
|
||||
## violence, strong language, or explicit sexual activity).
|
||||
}
|
||||
|
||||
enum TagForChildDirectedTreatment {
|
||||
UNSPECIFIED = -1, ## Means that you have not provided a specific instruction for your app's content to be treated
|
||||
## as child-directed for COPPA purposes.
|
||||
FALSE = 0, ## Means you are indicating that your app's content is not child-directed for the purposes of the
|
||||
## Children's Online Privacy Protection Act (COPPA).
|
||||
TRUE = 1, ## Tells ad networks to treat ad requests as if the content is directed towards children, which is
|
||||
## important for complying with regulations like COPPA.
|
||||
}
|
||||
|
||||
enum TagForUnderAgeOfConsent {
|
||||
UNSPECIFIED = -1, ## Means you have not provided any information on whether the user is under the age of consent,
|
||||
## so the system does not apply any specific treatment for these users based on this tag alone.
|
||||
FALSE = 0, ## Indicates that you do not want the specific ad request to receive the special handling suitable for
|
||||
## users under the age of consent in the EEA, the UK, and Switzerland.
|
||||
TRUE = 1, ## Indicates that you want the ad request to be handled in a manner suitable for users under the age of
|
||||
## consent within the EEA, the UK, and Switzerland.
|
||||
}
|
||||
|
||||
enum PersonalizationState {
|
||||
DEFAULT = 0, ## By default, the SDK will attempt to serve personalized ads based on the user's past behavior and
|
||||
## interests.
|
||||
ENABLED = 1, ## Indicates that the Google Mobile Ads SDK is authorized to serve ads that are tailored to the user
|
||||
## based on past activity and collected data.
|
||||
DISABLED = 2, ## Means that ad requests are set to serve non-personalized ads (NPA) only.
|
||||
}
|
||||
|
||||
const DATA_KEY_IS_REAL := &"is_real"
|
||||
const DATA_KEY_MAX_AD_CONTENT_RATING := &"max_ad_content_rating"
|
||||
const DATA_KEY_CHILD_DIRECTED_TREATMENT := &"tag_for_child_directed_treatment"
|
||||
const DATA_KEY_UNDER_AGE_OF_CONSENT := &"tag_for_under_age_of_consent"
|
||||
const DATA_KEY_FIRST_PARTY_ID_ENABLED := &"first_party_id_enabled"
|
||||
const DATA_KEY_PERSONALIZATION_STATE := &"personalization_state"
|
||||
const DATA_KEY_TEST_DEVICE_IDS := &"test_device_ids"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init():
|
||||
_data = {
|
||||
DATA_KEY_TEST_DEVICE_IDS: [],
|
||||
}
|
||||
|
||||
|
||||
func set_is_real(a_value: bool) -> AdmobConfig:
|
||||
_data[DATA_KEY_IS_REAL] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func set_max_ad_content_rating(a_value: ContentRating) -> AdmobConfig:
|
||||
_data[DATA_KEY_MAX_AD_CONTENT_RATING] = ContentRating.keys()[a_value]
|
||||
return self
|
||||
|
||||
|
||||
func set_child_directed_treatment(a_value: TagForChildDirectedTreatment) -> AdmobConfig:
|
||||
_data[DATA_KEY_CHILD_DIRECTED_TREATMENT] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func set_under_age_of_consent(a_value: TagForUnderAgeOfConsent) -> AdmobConfig:
|
||||
_data[DATA_KEY_UNDER_AGE_OF_CONSENT] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func set_first_party_id_enabled(a_value: bool) -> AdmobConfig:
|
||||
_data[DATA_KEY_FIRST_PARTY_ID_ENABLED] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func set_personalization_state(a_value: PersonalizationState) -> AdmobConfig:
|
||||
_data[DATA_KEY_PERSONALIZATION_STATE] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func set_test_device_ids(a_value: Array) -> AdmobConfig:
|
||||
if a_value == null:
|
||||
_data[DATA_KEY_TEST_DEVICE_IDS] = []
|
||||
else:
|
||||
_data[DATA_KEY_TEST_DEVICE_IDS] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func add_test_device_id(a_value: String) -> AdmobConfig:
|
||||
_data[DATA_KEY_TEST_DEVICE_IDS].append(a_value)
|
||||
return self
|
||||
|
||||
|
||||
func get_raw_data() -> Dictionary:
|
||||
return _data
|
||||
@@ -0,0 +1 @@
|
||||
uid://ihpl6qb5lkw2
|
||||
@@ -0,0 +1,46 @@
|
||||
#
|
||||
# © 2026-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name AdmobSettings
|
||||
extends RefCounted
|
||||
|
||||
const DATA_KEY_AD_VOLUME := &"ad_volume"
|
||||
const DATA_KEY_ADS_MUTED := &"ads_muted"
|
||||
const DATA_KEY_APPLY_AT_STARTUP := &"apply_at_startup"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary = {}) -> void:
|
||||
_data = a_data
|
||||
|
||||
|
||||
func set_ad_volume(a_value: float) -> AdmobSettings:
|
||||
_data[DATA_KEY_AD_VOLUME] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_ad_volume() -> float:
|
||||
return _data[DATA_KEY_AD_VOLUME]
|
||||
|
||||
|
||||
func set_ads_muted(a_value: bool) -> AdmobSettings:
|
||||
_data[DATA_KEY_ADS_MUTED] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func are_ads_muted() -> bool:
|
||||
return _data[DATA_KEY_ADS_MUTED]
|
||||
|
||||
|
||||
func set_apply_at_startup(a_value: bool) -> AdmobSettings:
|
||||
_data[DATA_KEY_APPLY_AT_STARTUP] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_apply_at_startup() -> bool:
|
||||
return _data[DATA_KEY_APPLY_AT_STARTUP]
|
||||
|
||||
|
||||
func get_raw_data() -> Dictionary:
|
||||
return _data
|
||||
@@ -0,0 +1 @@
|
||||
uid://u1bg67vtlhd8
|
||||
@@ -0,0 +1,63 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name ConsentRequestParameters
|
||||
extends RefCounted
|
||||
|
||||
enum DebugGeography {
|
||||
NOT_SET = -1, ## Don't specify location. (Actual location will be used.)
|
||||
DISABLED = 0, ## Use actual location.
|
||||
EEA = 1, ## Use European Economic Area.
|
||||
NOT_EEA = 2, ## Deprecated.
|
||||
REGULATED_US_STATE = 3, ## Use a regulated state of USA. (ie. California)
|
||||
OTHER = 4, ## Use any non-regulated location.
|
||||
}
|
||||
|
||||
const IS_REAL_PROPERTY := &"is_real"
|
||||
const TAG_FOR_UNDER_AGE_OF_CONSENT_PROPERTY := &"tag_for_under_age_of_consent"
|
||||
const DEBUG_GEOGRAPHY_PROPERTY := &"debug_geography"
|
||||
const TEST_DEVICE_HASHED_IDS_PROPERTY := &"test_device_hashed_ids"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init():
|
||||
_data = {
|
||||
TEST_DEVICE_HASHED_IDS_PROPERTY: [],
|
||||
}
|
||||
|
||||
|
||||
func set_is_real(a_value: bool) -> ConsentRequestParameters:
|
||||
_data[IS_REAL_PROPERTY] = a_value
|
||||
|
||||
return self
|
||||
|
||||
|
||||
func set_tag_for_under_age_of_consent(a_value: AdmobConfig.TagForUnderAgeOfConsent) -> ConsentRequestParameters:
|
||||
match a_value:
|
||||
AdmobConfig.TagForChildDirectedTreatment.TRUE:
|
||||
_data[TAG_FOR_UNDER_AGE_OF_CONSENT_PROPERTY] = true
|
||||
AdmobConfig.TagForChildDirectedTreatment.FALSE:
|
||||
_data[TAG_FOR_UNDER_AGE_OF_CONSENT_PROPERTY] = false
|
||||
|
||||
return self
|
||||
|
||||
|
||||
func set_debug_geography(a_value: DebugGeography) -> ConsentRequestParameters:
|
||||
_data[DEBUG_GEOGRAPHY_PROPERTY] = a_value
|
||||
|
||||
return self
|
||||
|
||||
|
||||
func add_test_device_hashed_id(a_value: String) -> ConsentRequestParameters:
|
||||
_data[TEST_DEVICE_HASHED_IDS_PROPERTY].append(a_value)
|
||||
|
||||
return self
|
||||
|
||||
|
||||
func get_device_ids() -> Array:
|
||||
return _data[TEST_DEVICE_HASHED_IDS_PROPERTY]
|
||||
|
||||
|
||||
func get_raw_data() -> Dictionary:
|
||||
return _data
|
||||
@@ -0,0 +1 @@
|
||||
uid://5nuqawjs80oi
|
||||
@@ -0,0 +1,22 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name FormError
|
||||
extends RefCounted
|
||||
|
||||
const CODE_PROPERTY := &"code"
|
||||
const MESSAGE_PROPERTY := &"message"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary):
|
||||
_data = a_data if a_data else {}
|
||||
|
||||
|
||||
func get_code() -> int:
|
||||
return _data[CODE_PROPERTY] if _data.has(CODE_PROPERTY) else 0
|
||||
|
||||
|
||||
func get_message() -> String:
|
||||
return _data[MESSAGE_PROPERTY] if _data.has(MESSAGE_PROPERTY) else ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://f4ywykxwjpal
|
||||
@@ -0,0 +1,22 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name InitializationStatus
|
||||
extends RefCounted
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary):
|
||||
if a_data == null:
|
||||
_data = {}
|
||||
else:
|
||||
_data = a_data
|
||||
|
||||
|
||||
func get_network_tags() -> Array:
|
||||
return _data.keys()
|
||||
|
||||
|
||||
func get_adapter_status(a_network_tag: String) -> AdapterStatus:
|
||||
return AdapterStatus.new(_data[a_network_tag]) if _data.has(a_network_tag) else null
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0oie3ngw68ha
|
||||
@@ -0,0 +1,15 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name LoadAdError
|
||||
extends AdError
|
||||
|
||||
const RESPONSE_INFO_PROPERTY := &"response_info"
|
||||
|
||||
|
||||
func _init(a_data: Dictionary):
|
||||
super(a_data)
|
||||
|
||||
|
||||
func get_response_info() -> ResponseInfo:
|
||||
return ResponseInfo.new(_data[RESPONSE_INFO_PROPERTY]) if _data.has(RESPONSE_INFO_PROPERTY) else null
|
||||
@@ -0,0 +1 @@
|
||||
uid://ddnpjk4ksplb5
|
||||
@@ -0,0 +1,332 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name LoadAdRequest
|
||||
extends RefCounted
|
||||
|
||||
enum AdPosition {
|
||||
TOP, ## Ad will be anchored at the top of the screen.
|
||||
BOTTOM, ## Ad will be anchored at the bottom of the screen.
|
||||
LEFT, ## Ad will be anchored to the left of the screen.
|
||||
RIGHT, ## Ad will be anchored to the right of the screen.
|
||||
TOP_LEFT, ## Ad will be anchored at the top-left of the screen.
|
||||
TOP_RIGHT, ## Ad will be anchored at the top-right of the screen.
|
||||
BOTTOM_LEFT, ## Ad will be anchored at the bottom-left of the screen.
|
||||
BOTTOM_RIGHT, ## Ad will be anchored at the bottom-right of the screen.
|
||||
CENTER, ## Ad will be anchored at the center of the screen.
|
||||
CUSTOM, ## Ad position will be set dynamically.
|
||||
}
|
||||
|
||||
enum RequestedAdSize {
|
||||
BANNER, ## A standard ad size that refers to a rectangular ad unit, most commonly 320x50 density-independent pixels
|
||||
## (dp).
|
||||
LARGE_BANNER, ## A fixed-size banner with dimensions of 320x100 density-independent pixels (dp).
|
||||
MEDIUM_RECTANGLE, ## A standard 300x250 pixel ad, often referred to as the Interactive Advertising Bureau (IAB)
|
||||
## medium rectangle.
|
||||
FULL_BANNER, ## An ad size constant for an Interactive Advertising Bureau (IAB) full-size banner, which has a fixed
|
||||
## size of 468x60 density-independent pixels (dp).
|
||||
LEADERBOARD, ## A banner ad with dimensions of 728 pixels wide by 90 pixels tall. This is a standard display ad
|
||||
## size that is designed for tablet screens.
|
||||
ADAPTIVE, ## An ad that automatically adjusts its width and height to fit the device's available screen space,
|
||||
## providing the best-performing banner size for that specific layout.
|
||||
INLINE_ADAPTIVE, ## An ad that dynamically optimizes its height for the available inline content width, ensuring a
|
||||
## smoothly integrated banner that fits naturally within scrollable or in-feed layouts.
|
||||
SKYSCRAPER, ## Deprecated
|
||||
FLUID, ## Deprecated
|
||||
}
|
||||
|
||||
enum CollapsiblePosition {
|
||||
DISABLED, ## The banner ad will not be collapsible.
|
||||
TOP, ## The banner ad will be collapsible from bottom to top.
|
||||
BOTTOM, ## The banner ad will be collapsible from top to bottom.
|
||||
}
|
||||
|
||||
## Controls which aspect ratios the SDK should prefer when requesting native ad media.
|
||||
## Maps to NativeAdOptions.NATIVE_MEDIA_ASPECT_RATIO_* constants.
|
||||
enum NativeMediaAspectRatio {
|
||||
UNKNOWN = 0, ## No preference for aspect ratio (SDK default).
|
||||
ANY = 1, ## Any aspect ratio is acceptable.
|
||||
LANDSCAPE = 2, ## Prefer landscape (wider than tall) media.
|
||||
PORTRAIT = 3, ## Prefer portrait (taller than wide) media.
|
||||
SQUARE = 4, ## Prefer square (1:1) media.
|
||||
}
|
||||
|
||||
## Controls where the AdChoices icon is placed within the native ad view.
|
||||
## Maps to NativeAdOptions.ADCHOICES_* constants.
|
||||
enum NativeAdChoicesPlacement {
|
||||
TOP_LEFT = 0, ## AdChoices icon in the top-left corner.
|
||||
TOP_RIGHT = 1, ## AdChoices icon in the top-right corner (SDK default).
|
||||
BOTTOM_RIGHT = 2, ## AdChoices icon in the bottom-right corner.
|
||||
BOTTOM_LEFT = 3, ## AdChoices icon in the bottom-left corner.
|
||||
}
|
||||
|
||||
## Controls how icon/image assets are scaled inside their ImageView.
|
||||
## Maps directly to Android's ImageView.ScaleType enum.
|
||||
enum NativeImageScaleType {
|
||||
MATRIX = 0, ## Scale using the image Matrix.
|
||||
FIT_XY = 1, ## Scale to fill the view bounds, ignoring aspect ratio.
|
||||
FIT_START = 2, ## Scale to fit within bounds, aligned to top-left.
|
||||
FIT_CENTER = 3, ## Scale to fit within bounds, centered (Android default).
|
||||
FIT_END = 4, ## Scale to fit within bounds, aligned to bottom-right.
|
||||
CENTER = 5, ## Center image without scaling.
|
||||
CENTER_CROP = 6, ## Scale so shorter dimension fits; crop the longer one.
|
||||
CENTER_INSIDE = 7, ## Scale to fit within bounds if larger; center otherwise.
|
||||
}
|
||||
|
||||
const COLLAPSIBLE_POSITION_NAMES: Dictionary = {
|
||||
CollapsiblePosition.TOP: "top",
|
||||
CollapsiblePosition.BOTTOM: "bottom",
|
||||
}
|
||||
|
||||
const DATA_KEY_AD_UNIT_ID := &"ad_unit_id"
|
||||
const DATA_KEY_REQUEST_AGENT := &"request_agent"
|
||||
const DATA_KEY_AD_SIZE := &"ad_size"
|
||||
const DATA_KEY_ADAPTIVE_WIDTH := &"adaptive_width"
|
||||
const DATA_KEY_ADAPTIVE_MAX_HEIGHT := &"adaptive_max_height"
|
||||
const DATA_KEY_AD_POSITION := &"ad_position"
|
||||
const DATA_KEY_COLLAPSIBLE_POSITION := &"collapsible_position"
|
||||
const DATA_KEY_ANCHOR_TO_SAFE_AREA := &"anchor_to_safe_area"
|
||||
const DATA_KEY_KEYWORDS := &"keywords"
|
||||
const DATA_KEY_USER_ID := &"user_id"
|
||||
const DATA_KEY_CUSTOM_DATA := &"custom_data"
|
||||
const DATA_KEY_NETWORK_EXTRAS := &"network_extras"
|
||||
|
||||
# Native ad option keys — sent to the Java layer inside the ad-request dictionary.
|
||||
const DATA_KEY_NATIVE_MEDIA_ASPECT_RATIO := &"native_media_aspect_ratio"
|
||||
const DATA_KEY_NATIVE_RETURN_URLS_FOR_IMAGE_ASSETS := &"native_return_urls_for_image_assets"
|
||||
const DATA_KEY_NATIVE_REQUEST_MULTIPLE_IMAGES := &"native_request_multiple_images"
|
||||
const DATA_KEY_NATIVE_AD_CHOICES_PLACEMENT := &"native_ad_choices_placement"
|
||||
const DATA_KEY_NATIVE_IMAGE_SCALE_TYPE := &"native_image_scale_type"
|
||||
const DATA_KEY_NATIVE_DISABLE_VALIDATOR := &"native_disable_validator"
|
||||
|
||||
const DEFAULT_DATA: Dictionary = {
|
||||
DATA_KEY_KEYWORDS: [],
|
||||
DATA_KEY_NETWORK_EXTRAS: [],
|
||||
}
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary = DEFAULT_DATA.duplicate()) -> void:
|
||||
_data = a_data
|
||||
|
||||
|
||||
func set_ad_unit_id(a_value: String) -> LoadAdRequest:
|
||||
_data[DATA_KEY_AD_UNIT_ID] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_ad_unit_id() -> String:
|
||||
return _data[DATA_KEY_AD_UNIT_ID]
|
||||
|
||||
|
||||
func set_request_agent(a_value: String) -> LoadAdRequest:
|
||||
_data[DATA_KEY_REQUEST_AGENT] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_request_agent() -> String:
|
||||
return _data[DATA_KEY_REQUEST_AGENT]
|
||||
|
||||
|
||||
func set_ad_size(a_value: RequestedAdSize) -> LoadAdRequest:
|
||||
_data[DATA_KEY_AD_SIZE] = RequestedAdSize.keys()[a_value]
|
||||
return self
|
||||
|
||||
|
||||
func get_ad_size() -> RequestedAdSize:
|
||||
return RequestedAdSize[_data[DATA_KEY_AD_SIZE]] if _data.has(DATA_KEY_AD_SIZE) else RequestedAdSize.BANNER
|
||||
|
||||
|
||||
func set_adaptive_width(a_value: int) -> LoadAdRequest:
|
||||
_data[DATA_KEY_ADAPTIVE_WIDTH] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_adaptive_width() -> int:
|
||||
return _data[DATA_KEY_ADAPTIVE_WIDTH]
|
||||
|
||||
|
||||
func set_adaptive_max_height(a_value: int) -> LoadAdRequest:
|
||||
_data[DATA_KEY_ADAPTIVE_MAX_HEIGHT] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_adaptive_max_height() -> int:
|
||||
return _data[DATA_KEY_ADAPTIVE_MAX_HEIGHT]
|
||||
|
||||
|
||||
func set_ad_position(a_value: AdPosition) -> LoadAdRequest:
|
||||
_data[DATA_KEY_AD_POSITION] = AdPosition.keys()[a_value]
|
||||
return self
|
||||
|
||||
|
||||
func get_ad_position() -> AdPosition:
|
||||
return AdPosition[_data[DATA_KEY_AD_POSITION]] if _data.has(DATA_KEY_AD_POSITION) else AdPosition.CUSTOM
|
||||
|
||||
|
||||
func set_collapsible_position(a_value: CollapsiblePosition) -> LoadAdRequest:
|
||||
if a_value != CollapsiblePosition.DISABLED:
|
||||
_data[DATA_KEY_COLLAPSIBLE_POSITION] = COLLAPSIBLE_POSITION_NAMES[a_value]
|
||||
return self
|
||||
|
||||
|
||||
func get_collapsible_position() -> CollapsiblePosition:
|
||||
return (
|
||||
CollapsiblePosition[_data[DATA_KEY_COLLAPSIBLE_POSITION]]
|
||||
if _data.has(DATA_KEY_COLLAPSIBLE_POSITION)
|
||||
else CollapsiblePosition.DISABLED
|
||||
)
|
||||
|
||||
|
||||
func set_anchor_to_safe_area(a_value: bool) -> LoadAdRequest:
|
||||
_data[DATA_KEY_ANCHOR_TO_SAFE_AREA] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_anchor_to_safe_area() -> bool:
|
||||
return _data[DATA_KEY_ANCHOR_TO_SAFE_AREA] if _data.has(DATA_KEY_ANCHOR_TO_SAFE_AREA) else false
|
||||
|
||||
|
||||
func set_keywords(a_value: Array) -> LoadAdRequest:
|
||||
if a_value == null:
|
||||
_data[DATA_KEY_KEYWORDS] = []
|
||||
else:
|
||||
_data[DATA_KEY_KEYWORDS] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func add_keyword(a_value: String) -> LoadAdRequest:
|
||||
_data[DATA_KEY_KEYWORDS].append(a_value)
|
||||
return self
|
||||
|
||||
|
||||
func get_keywords() -> Array:
|
||||
return _data[DATA_KEY_KEYWORDS] if _data.has(DATA_KEY_KEYWORDS) else []
|
||||
|
||||
|
||||
func set_user_id(a_value: String) -> LoadAdRequest:
|
||||
_data[DATA_KEY_USER_ID] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_user_id() -> String:
|
||||
return _data[DATA_KEY_USER_ID] if _data.has(DATA_KEY_USER_ID) else ""
|
||||
|
||||
|
||||
func set_custom_data(a_value: String) -> LoadAdRequest:
|
||||
_data[DATA_KEY_CUSTOM_DATA] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_custom_data() -> String:
|
||||
return _data[DATA_KEY_CUSTOM_DATA] if _data.has(DATA_KEY_CUSTOM_DATA) else ""
|
||||
|
||||
|
||||
func set_network_extras(a_value: Array) -> LoadAdRequest:
|
||||
if a_value == null:
|
||||
_data[DATA_KEY_NETWORK_EXTRAS] = []
|
||||
else:
|
||||
_data[DATA_KEY_NETWORK_EXTRAS] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_network_extras() -> Array:
|
||||
return _data[DATA_KEY_NETWORK_EXTRAS] if _data.has(DATA_KEY_NETWORK_EXTRAS) else []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Native Ad Options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
## Sets a preferred media aspect ratio for native ads.
|
||||
## Has no effect when used for non-native ad types.
|
||||
func set_native_media_aspect_ratio(a_value: NativeMediaAspectRatio) -> LoadAdRequest:
|
||||
_data[DATA_KEY_NATIVE_MEDIA_ASPECT_RATIO] = NativeMediaAspectRatio.keys()[a_value]
|
||||
return self
|
||||
|
||||
|
||||
func get_native_media_aspect_ratio() -> NativeMediaAspectRatio:
|
||||
return (
|
||||
NativeMediaAspectRatio[_data[DATA_KEY_NATIVE_MEDIA_ASPECT_RATIO]]
|
||||
if _data.has(DATA_KEY_NATIVE_MEDIA_ASPECT_RATIO)
|
||||
else NativeMediaAspectRatio.UNKNOWN
|
||||
)
|
||||
|
||||
|
||||
## When true the SDK returns image asset URLs instead of pre-fetched Drawable
|
||||
## objects, allowing the app to download and manage images itself.
|
||||
## Has no effect when used for non-native ad types.
|
||||
func set_native_return_urls_for_image_assets(a_value: bool) -> LoadAdRequest:
|
||||
_data[DATA_KEY_NATIVE_RETURN_URLS_FOR_IMAGE_ASSETS] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_native_return_urls_for_image_assets() -> bool:
|
||||
return (
|
||||
_data[DATA_KEY_NATIVE_RETURN_URLS_FOR_IMAGE_ASSETS]
|
||||
if _data.has(DATA_KEY_NATIVE_RETURN_URLS_FOR_IMAGE_ASSETS)
|
||||
else false
|
||||
)
|
||||
|
||||
|
||||
## When true the SDK may return ads that contain multiple images for a single
|
||||
## asset slot. By default only one image per slot is returned.
|
||||
## Has no effect when used for non-native ad types.
|
||||
func set_native_request_multiple_images(a_value: bool) -> LoadAdRequest:
|
||||
_data[DATA_KEY_NATIVE_REQUEST_MULTIPLE_IMAGES] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_native_request_multiple_images() -> bool:
|
||||
return (
|
||||
_data[DATA_KEY_NATIVE_REQUEST_MULTIPLE_IMAGES] if _data.has(DATA_KEY_NATIVE_REQUEST_MULTIPLE_IMAGES) else false
|
||||
)
|
||||
|
||||
|
||||
## Sets where the AdChoices icon is placed inside the native ad view.
|
||||
## Has no effect when used for non-native ad types.
|
||||
func set_native_ad_choices_placement(a_value: NativeAdChoicesPlacement) -> LoadAdRequest:
|
||||
_data[DATA_KEY_NATIVE_AD_CHOICES_PLACEMENT] = NativeAdChoicesPlacement.keys()[a_value]
|
||||
return self
|
||||
|
||||
|
||||
func get_native_ad_choices_placement() -> NativeAdChoicesPlacement:
|
||||
return (
|
||||
NativeAdChoicesPlacement[_data[DATA_KEY_NATIVE_AD_CHOICES_PLACEMENT]]
|
||||
if _data.has(DATA_KEY_NATIVE_AD_CHOICES_PLACEMENT)
|
||||
else NativeAdChoicesPlacement.TOP_RIGHT
|
||||
)
|
||||
|
||||
|
||||
## Sets how icon and image assets are scaled inside their Android ImageView.
|
||||
## Has no effect when used for non-native ad types.
|
||||
func set_native_image_scale_type(a_value: NativeImageScaleType) -> LoadAdRequest:
|
||||
_data[DATA_KEY_NATIVE_IMAGE_SCALE_TYPE] = NativeImageScaleType.keys()[a_value]
|
||||
return self
|
||||
|
||||
|
||||
func get_native_image_scale_type() -> NativeImageScaleType:
|
||||
return (
|
||||
NativeImageScaleType[_data[DATA_KEY_NATIVE_IMAGE_SCALE_TYPE]]
|
||||
if _data.has(DATA_KEY_NATIVE_IMAGE_SCALE_TYPE)
|
||||
else NativeImageScaleType.FIT_CENTER
|
||||
)
|
||||
|
||||
|
||||
## When true the SDK's native ad validator is disabled. The validator normally
|
||||
## logs warnings when required view bindings are missing; disabling it suppresses
|
||||
## those warnings in test / custom-layout scenarios.
|
||||
## Has no effect when used for non-native ad types.
|
||||
func set_native_disable_validator(a_value: bool) -> LoadAdRequest:
|
||||
_data[DATA_KEY_NATIVE_DISABLE_VALIDATOR] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_native_disable_validator() -> bool:
|
||||
return _data[DATA_KEY_NATIVE_DISABLE_VALIDATOR] if _data.has(DATA_KEY_NATIVE_DISABLE_VALIDATOR) else false
|
||||
|
||||
|
||||
func get_raw_data() -> Dictionary:
|
||||
return _data
|
||||
@@ -0,0 +1 @@
|
||||
uid://c86msp23213bn
|
||||
@@ -0,0 +1,322 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name MediationNetwork
|
||||
extends RefCounted
|
||||
|
||||
enum Flag {
|
||||
APPLOVIN = 1 << 0, ## AppLovin
|
||||
CHARTBOOST = 1 << 1, ## Chartboost
|
||||
DTEXCHANGE = 1 << 2, ## DT Exchange (previously Fyber)
|
||||
IMOBILE = 1 << 3, ## i-mobile
|
||||
INMOBI = 1 << 4, ## InMobi
|
||||
IRONSOURCE = 1 << 5, ## ironSource
|
||||
LIFTOFF = 1 << 6, ## Liftoff Monetize (previously Vungle)
|
||||
LINE = 1 << 7, ## LINE Ads Network
|
||||
MAIO = 1 << 8, ## maio
|
||||
META = 1 << 9, ## Meta Audience Network (previously Facebook)
|
||||
MINTEGRAL = 1 << 10, ## Mintegral
|
||||
MOLOCO = 1 << 11, ## Moloco
|
||||
MYTARGET = 1 << 12, ## myTarget
|
||||
PANGLE = 1 << 13, ## Pangle
|
||||
UNITY = 1 << 14, ## Unity Ads
|
||||
}
|
||||
|
||||
const FLAG_PROPERTY := &"flag"
|
||||
const TAG_PROPERTY := &"tag"
|
||||
const DEPENDENCIES_PROPERTY := &"dependencies"
|
||||
const MAVEN_REPO_PROPERTY := &"maven_repo"
|
||||
const PACKAGE_PROPERTY := &"package"
|
||||
const PACKAGE_URL_PROPERTY := &"package_url"
|
||||
const PACKAGE_VERSION_PROPERTY := &"package_version"
|
||||
const SK_AD_NETWORK_IDS_PROPERTY := &"sk_ad_network_ids"
|
||||
const GOOGLE_SK_AD_NETWORK_IDS = [ "cstr6suwn9", "4fzdc2evr5", "2fnua5tdw4", "ydx93a7ass", "p78axxw29g", "v72qych5uu", "ludvb6z3bs", "cp8zw746q7", "3sh42y64q3", "c6k4g5qg8m", "s39g8k73mm", "3qy4746246", "f38h382jlk", "hs6bdukanm", "mlmmfzh3r3", "v4nxqhlyqp", "wzmmz9fp6w", "su67r6k2v3", "yclnxrl5pm", "t38b2kh725", "7ug5zh24hu", "gta9lk7p23", "vutu7akeur", "y5ghdn5j9k", "v9wttpbfk9", "n38lu8286q", "47vhws6wlr", "kbd757ywx3", "9t245vhmpl", "a2p9lx4jpn", "22mmun2rn5", "44jx6755aq", "k674qkevps", "4468km3ulz", "2u9pt9hc89", "8s468mfl3y", "klf5c3l5u5", "ppxm28t8ap", "kbmxgpxpgc", "uw77j35x4d", "578prtvx9j", "4dzt52r2t5", "tl55sbb4fm", "c3frkrj4fj", "e5fvkxwrpn", "8c4e2ghe7u", "3rd42ekr43", "97r2b46745", "3qcr597p9d" ]
|
||||
|
||||
const SK_AD_NETWORK_ITEM_LIST_FORMAT: String = """
|
||||
<key>SKAdNetworkItems</key>
|
||||
<array>
|
||||
%s
|
||||
</array>
|
||||
"""
|
||||
|
||||
const SK_AD_NETWORK_ITEM_FORMAT: String = """
|
||||
<dict>
|
||||
<key>SKAdNetworkIdentifier</key>
|
||||
<string>%s.skadnetwork</string>
|
||||
</dict>
|
||||
"""
|
||||
|
||||
const MEDIATION_NETWORKS: Dictionary = {
|
||||
Flag.APPLOVIN: {
|
||||
FLAG_PROPERTY: Flag.APPLOVIN,
|
||||
TAG_PROPERTY: "applovin",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:applovin:13.4.0.1"],
|
||||
MAVEN_REPO_PROPERTY: "",
|
||||
PACKAGE_PROPERTY: "AppLovinAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-applovin.git",
|
||||
PACKAGE_VERSION_PROPERTY: "13.4.0",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["22mmun2rn5", "238da6jt44", "24t9a8vw3c", "24zw6aqk47", "252b5q8x7y", "275upjj5gd", "294l99pt4k", "2fnua5tdw4", "2u9pt9hc89", "32z4fx6l9h", "3l6bd9hu43", "3qcr597p9d", "3qy4746246", "3rd42ekr43", "3sh42y64q3", "424m5254lk", "4468km3ulz", "44jx6755aq", "44n7hlldy6", "47vhws6wlr", "488r3q3dtq", "4dzt52r2t5", "4fzdc2evr5", "4mn522wn87", "4pfyvq9l8r", "4w7y6s5ca2", "523jb4fst2", "52fl2v3hgk", "54nzkqm89y", "578prtvx9j", "5a6flpkh64", "5l3tpt7t6e", "5lm9lj6jb7", "5tjdwbrq8w", "6964rsfnh4", "6g9af3uyq4", "6p4ks3rnbw", "6v7lgmsu45", "6xzpu9s2p8", "737z793b9f", "74b6s63p6l", "79pbpufp6p", "7fmhfwg9en", "7rz58n8ntl", "7ug5zh24hu", "84993kbrcf", "89z7zv988g", "8c4e2ghe7u", "8m87ys6875", "8r8llnkz5a", "8s468mfl3y", "97r2b46745", "9b89h5y424", "9nlqeag3gk", "9rd848q2bz", "9t245vhmpl", "9vvzujtq5s", "9yg77x724h", "a2p9lx4jpn", "a7xqa6mtl2", "a8cz6cu7e5", "av6w8kgt66", "b9bk5wbcq9", "bxvub5ada5", "c3frkrj4fj", "c6k4g5qg8m", "cg4yq2srnc", "cj5566h2ga", "cp8zw746q7", "cs644xg564", "cstr6suwn9", "dbu4b84rxf", "dkc879ngq3", "dzg6xy7pwj", "e5fvkxwrpn", "ecpz2srf59", "eh6m2bh4zr", "ejvt5qm6ak", "f38h382jlk", "f73kdq92p3", "f7s53z58qe", "feyaarzu9v", "g28c52eehv", "g2y4y55b64", "g6gcrrvk4p", "ggvn48r87g", "glqzh8vgby", "gta8lk7p23", "gta9lk7p23", "hb56zgv37p", "hdw39hrw9y", "hs6bdukanm", "k674qkevps", "kbd757ywx3", "kbmxgpxpgc", "klf5c3l5u5", "krvm3zuq6h", "lr83yxwka7", "ludvb6z3bs", "m297p6643m", "m5mvw97r93", "m8dbw4sv7c", "mj797d8u6f", "mlmmfzh3r3", "mls7yz5dvl", "mp6xlyr22a", "mqn7fxpca7", "mtkv5xtk9e", "n38lu8286q", "n66cz3y3bx", "n6fk4nfna4", "n9x2a789qt", "nzq8sh4pbs", "p78axxw29g", "ppxm28t8ap", "prcb7njmu6", "pwa73g5rt2", "pwdxu55a5a", "qqp299437r", "qu637u8glc", "r45fhb6rf7", "rvh3l7un93", "rx5hdcabgc", "s39g8k73mm", "s69wq72ugq", "su67r6k2v3", "t38b2kh725", "tl55sbb4fm", "u679fj5vs4", "uw77j35x4d", "v4nxqhlyqp", "v72qych5uu", "v79kvwwj4g", "v9wttpbfk9", "vcra2ehyfk", "vhf287vqwu", "vutu7akeur", "w9q455wk68", "wg4vff78zm", "wzmmz9fp6w", "x44k69ngh6", "x5l83yy675", "x8jxxk4ff5", "x8uqf25wch", "xga6mpmplv", "xy9t38ct57", "y45688jllp", "y5ghdn5j9k", "yclnxrl5pm", "ydx93a7ass", "zmvfpc5aq8", "zq492l623r"],
|
||||
},
|
||||
Flag.CHARTBOOST: {
|
||||
FLAG_PROPERTY: Flag.CHARTBOOST,
|
||||
TAG_PROPERTY: "chartboost",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:chartboost:9.10.0.1"],
|
||||
MAVEN_REPO_PROPERTY: "https://cboost.jfrog.io/artifactory/chartboost-ads/",
|
||||
PACKAGE_PROPERTY: "ChartboostAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-chartboost.git",
|
||||
PACKAGE_VERSION_PROPERTY: "9.11.001",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["f38h382jlk"],
|
||||
},
|
||||
Flag.DTEXCHANGE: {
|
||||
FLAG_PROPERTY: Flag.DTEXCHANGE,
|
||||
TAG_PROPERTY: "dtexchange",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:fyber:8.4.0.0"],
|
||||
MAVEN_REPO_PROPERTY: "",
|
||||
PACKAGE_PROPERTY: "DTExchangeAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-dtexchange.git",
|
||||
PACKAGE_VERSION_PROPERTY: "8.4.600",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["ydx93a7ass", "t6d3zquu66", "4fzdc2evr5", "a8cz6cu7e5", "9nlqeag3gk", "a2p9lx4jpn", "k674qkevps", "c3frkrj4fj", "n6fk4nfna4", "mtkv5xtk9e", "p78axxw29g", "n9x2a789qt", "kbmxgpxpgc", "s39g8k73mm", "krvm3zuq6h", "294l99pt4k", "3rd42ekr43", "ggvn48r87g", "4pfyvq9l8r", "cg4yq2srnc", "5a6flpkh64", "v72qych5uu", "x8uqf25wch", "c6k4g5qg8m", "wg4vff78zm", "rx5hdcabgc", "32z4fx6l9h", "3qy4746246", "252b5q8x7y", "f38h382jlk", "24t9a8vw3c", "hs6bdukanm", "9rd848q2bz", "prcb7njmu6", "m8dbw4sv7c", "9g2aggbj52", "wzmmz9fp6w", "yclnxrl5pm", "t38b2kh725", "7ug5zh24hu", "5lm9lj6jb7", "ejvt5qm6ak", "7rz58n8ntl", "kbd757ywx3", "9t245vhmpl", "44jx6755aq", "tl55sbb4fm", "8s468mfl3y", "4468km3ulz", "2u9pt9hc89", "av6w8kgt66", "klf5c3l5u5", "hdw39hrw9y", "y45688jllp", "dzg6xy7pwj", "3sh42y64q3", "ppxm28t8ap", "f73kdq92p3", "5l3tpt7t6e", "uw77j35x4d", "mlmmfzh3r3", "6g9af3uyq4", "su67r6k2v3", "g28c52eehv", "pwa73g5rt2", "zq492l623r", "22mmun2rn5", "8r8llnkz5a", "47vhws6wlr", "5tjdwbrq8w", "h65wbv5k3f", "2fnua5tdw4", "7fmhfwg9en", "tvvz7th9br", "x44k69ngh6", "vhf287vqwu", "4dzt52r2t5", "x8yj322td6", "tskbem2b5g", "cp8zw746q7", "cj5566h2ga", "578prtvx9j", "e5fvkxwrpn", "r45fhb6rf7", "mqn7fxpca7", "g6gcrrvk4p", "xga6mpmplv", "qu637u8glc", "w9q455wk68", "275upjj5gd", "97r2b46745", "cstr6suwn9"],
|
||||
},
|
||||
Flag.IMOBILE: {
|
||||
FLAG_PROPERTY: Flag.IMOBILE,
|
||||
TAG_PROPERTY: "imobile",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:imobile:2.3.2.1"],
|
||||
MAVEN_REPO_PROPERTY: "https://imobile.github.io/adnw-sdk-android",
|
||||
PACKAGE_PROPERTY: "IMobileAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-imobile.git",
|
||||
PACKAGE_VERSION_PROPERTY: "2.3.402",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["v4nxqhlyqp"],
|
||||
},
|
||||
Flag.INMOBI: {
|
||||
FLAG_PROPERTY: Flag.INMOBI,
|
||||
TAG_PROPERTY: "inmobi",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:inmobi:10.8.8.1"],
|
||||
MAVEN_REPO_PROPERTY: "",
|
||||
PACKAGE_PROPERTY: "InMobiAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-inmobi.git",
|
||||
PACKAGE_VERSION_PROPERTY: "10.8.600",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["uw77j35x4d", "7ug5zh24hu", "hs6bdukanm", "4fzdc2evr5", "ggvn48r87g", "5lm9lj6jb7", "9rd848q2bz", "c6k4g5qg8m", "wzmmz9fp6w", "3sh42y64q3", "yclnxrl5pm", "kbd757ywx3", "f73kdq92p3", "ydx93a7ass", "w9q455wk68", "prcb7njmu6", "wg4vff78zm", "mlmmfzh3r3", "tl55sbb4fm", "4pfyvq9l8r", "t38b2kh725", "5l3tpt7t6e", "7rz58n8ntl", "klf5c3l5u5", "cg4yq2srnc", "av6w8kgt66", "9t245vhmpl", "v72qych5uu", "2u9pt9hc89", "44jx6755aq", "8s468mfl3y", "p78axxw29g", "ppxm28t8ap", "424m5254lk", "5a6flpkh64", "pwa73g5rt2", "e5fvkxwrpn", "9nlqeag3gk", "k674qkevps", "cj5566h2ga", "mtkv5xtk9e", "3rd42ekr43", "g28c52eehv", "2fnua5tdw4", "4468km3ulz", "97r2b46745", "glqzh8vgby", "3qcr597p9d", "578prtvx9j", "n6fk4nfna4", "b9bk5wbcq9", "kbmxgpxpgc", "294l99pt4k", "523jb4fst2", "mls7yz5dvl", "74b6s63p6l", "22mmun2rn5", "52fl2v3hgk", "6g9af3uyq4", "x5l83yy675", "mqn7fxpca7", "g6gcrrvk4p", "r45fhb6rf7", "c3frkrj4fj", "ejvt5qm6ak", "f38h382jlk", "488r3q3dtq", "55644vm79v", "5tjdwbrq8w", "nzq8sh4pbs", "m8dbw4sv7c", "7fmhfwg9en", "6v7lgmsu45", "s39g8k73mm", "6yxyv74ff7", "55y65gfgn7", "mqn7fxpca7", "qqp299437r", "32z4fx6l9h", "feyaarzu9v", "cp8zw746q7", "252b5q8x7y", "qu637u8glc", "vhf287vqwu", "a8cz6cu7e5", "cwn433xbcr", "fq6vru337s", "87u5trcl3r", "mj797d8u6f", "dbu4b84rxf", "238da6jt44", "4dzt52r2t5", "a2p9lx4jpn", "t6d3zquu66", "zq492l623r", "cstr6suwn9", "xga6mpmplv", "tmhh9296z4", "6xzpu9s2p8", "ludvb6z3bs", "v9wttpbfk9", "n38lu8286q", "fz2k2k5tej", "g2y4y55b64", "su67r6k2v3", "v79kvwwj4g", "275upjj5gd", "24zw6aqk47", "44n7hlldy6", "ecpz2srf59", "bvpn9ufa9b", "gta9lk7p23"],
|
||||
},
|
||||
Flag.IRONSOURCE: {
|
||||
FLAG_PROPERTY: Flag.IRONSOURCE,
|
||||
TAG_PROPERTY: "ironsource",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:ironsource:9.0.0.1"],
|
||||
MAVEN_REPO_PROPERTY: "",
|
||||
PACKAGE_PROPERTY: "IronSourceAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-ironsource.git",
|
||||
PACKAGE_VERSION_PROPERTY: "8.10.0",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["su67r6k2v3"],
|
||||
},
|
||||
Flag.LIFTOFF: {
|
||||
FLAG_PROPERTY: Flag.LIFTOFF,
|
||||
TAG_PROPERTY: "liftoff",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:vungle:7.6.0.0"],
|
||||
MAVEN_REPO_PROPERTY: "",
|
||||
PACKAGE_PROPERTY: "LiftoffMonetizeAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-liftoffmonetize.git",
|
||||
PACKAGE_VERSION_PROPERTY: "7.6.0",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["22mmun2rn5", "238da6jt44", "24t9a8vw3c", "24zw6aqk47", "275upjj5gd", "294l99pt4k", "2fnua5tdw4", "2u9pt9hc89", "32z4fx6l9h", "3l6bd9hu43", "3qcr597p9d", "3qy4746246", "3rd42ekr43", "3sh42y64q3", "424m5254lk", "4468km3ulz", "44jx6755aq", "44n7hlldy6", "488r3q3dtq", "4dzt52r2t5", "4fzdc2evr5", "4pfyvq9l8r", "4w7y6s5ca2", "523jb4fst2", "52fl2v3hgk", "54nzkqm89y", "578prtvx9j", "5a6flpkh64", "5l3tpt7t6e", "5lm9lj6jb7", "5tjdwbrq8w", "6964rsfnh4", "6g9af3uyq4", "6lz2ygh3q6", "6p4ks3rnbw", "6rd35atwn8", "6v7lgmsu45", "6xzpu9s2p8", "6yxyv74ff7", "737z793b9f", "74b6s63p6l", "79pbpufp6p", "7fmhfwg9en", "7rz58n8ntl", "7ug5zh24hu", "84993kbrcf", "87u5trcl3r", "89z7zv988g", "8m87ys6875", "8s468mfl3y", "97r2b46745", "9b89h5y424", "9nlqeag3gk", "9rd848q2bz", "9t245vhmpl", "9vvzujtq5s", "a2p9lx4jpn", "a7xqa6mtl2", "a8cz6cu7e5", "apzhy3va96", "av6w8kgt66", "b9bk5wbcq9", "bvpn9ufa9b", "bxvub5ada5", "c3frkrj4fj", "c6k4g5qg8m", "cg4yq2srnc", "cj5566h2ga", "cp8zw746q7", "cs644xg564", "cstr6suwn9", "cwn433xbcr", "dbu4b84rxf", "dkc879ngq3", "dzg6xy7pwj", "e5fvkxwrpn", "ecpz2srf59", "ejvt5qm6ak", "f2zub97jtl", "f38h382jlk", "f73kdq92p3", "f7s53z58qe", "feyaarzu9v", "fq6vru337s", "fz2k2k5tej", "g28c52eehv", "g2y4y55b64", "g6gcrrvk4p", "ggvn48r87g", "glqzh8vgby", "gta9lk7p23", "hb56zgv37p", "hdw39hrw9y", "hs6bdukanm", "k674qkevps", "k6y4y55b64", "kbd757ywx3", "kbmxgpxpgc", "klf5c3l5u5", "krvm3zuq6h", "ln5gz23vtd", "lr83yxwka7", "ludvb6z3bs", "m297p6643m", "m2jqnlggk3", "m5mvw97r93", "m8dbw4sv7c", "mj797d8u6f", "mlmmfzh3r3", "mls7yz5dvl", "mp6xlyr22a", "mqn7fxpca7", "mtkv5xtk9e", "n38lu8286q", "n6fk4nfna4", "n9x2a789qt", "ns5j362hk7", "p78axxw29g", "pg7ctvrt6f", "ppxm28t8ap", "prcb7njmu6", "pwa73g5rt2", "pwdxu55a5a", "qqp299437r", "qu637u8glc", "qwpu75vrh2", "r45fhb6rf7", "raa6f494kr", "rvh3l7un93", "rx5hdcabgc", "s39g8k73mm", "sczv5946wb", "su67r6k2v3", "t38b2kh725", "t6d3zquu66", "thzdn4h5nc", "tl55sbb4fm", "tmhh9296z4", "u679fj5vs4", "uw77j35x4d", "v72qych5uu", "v79kvwwj4g", "v9wttpbfk9", "vcra2ehyfk", "vhf287vqwu", "w9q455wk68", "wg4vff78zm", "wzmmz9fp6w", "x44k69ngh6", "x5l83yy675", "x8uqf25wch", "xga6mpmplv", "xy9t38ct57", "y45688jllp", "y5ghdn5j9k", "yclnxrl5pm", "ydx93a7ass", "z959bm4gru", "zmvfpc5aq8", "zq492l623r"],
|
||||
},
|
||||
Flag.LINE: {
|
||||
FLAG_PROPERTY: Flag.LINE,
|
||||
TAG_PROPERTY: "line",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:line:2.9.20250924.1"],
|
||||
MAVEN_REPO_PROPERTY: "",
|
||||
PACKAGE_PROPERTY: "LineAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-line.git",
|
||||
PACKAGE_VERSION_PROPERTY: "2.9.2025111900",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["vutu7akeur", "eh6m2bh4zr", "cstr6suwn9", "578prtvx9j", "9t245vhmpl", "v72qych5uu", "x8uqf25wch", "7ug5zh24hu", "hs6bdukanm", "dbu4b84rxf", "8c4e2ghe7u"],
|
||||
},
|
||||
Flag.MAIO: {
|
||||
FLAG_PROPERTY: Flag.MAIO,
|
||||
TAG_PROPERTY: "maio",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:maio:2.0.6.0"],
|
||||
MAVEN_REPO_PROPERTY: "https://imobile-maio.github.io/maven",
|
||||
PACKAGE_PROPERTY: "MaioAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-maio.git",
|
||||
PACKAGE_VERSION_PROPERTY: "2.2.0",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["v4nxqhlyqp"],
|
||||
},
|
||||
Flag.META: {
|
||||
FLAG_PROPERTY: Flag.META,
|
||||
TAG_PROPERTY: "meta",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:facebook:6.20.0.2"],
|
||||
MAVEN_REPO_PROPERTY: "",
|
||||
PACKAGE_PROPERTY: "MetaAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-meta.git",
|
||||
PACKAGE_VERSION_PROPERTY: "6.20.100",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["v9wttpbfk9", "n38lu8286q"],
|
||||
},
|
||||
Flag.MINTEGRAL: {
|
||||
FLAG_PROPERTY: Flag.MINTEGRAL,
|
||||
TAG_PROPERTY: "mintegral",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:mintegral:16.9.91.2"],
|
||||
MAVEN_REPO_PROPERTY: "https://dl-maven-android.mintegral.com/repository/mbridge_android_sdk_oversea",
|
||||
PACKAGE_PROPERTY: "MintegralAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-mintegral.git",
|
||||
PACKAGE_VERSION_PROPERTY: "7.7.901",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["kbd757ywx3"],
|
||||
},
|
||||
Flag.MOLOCO: {
|
||||
FLAG_PROPERTY: Flag.MOLOCO,
|
||||
TAG_PROPERTY: "moloco",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:moloco:4.2.0.0"],
|
||||
MAVEN_REPO_PROPERTY: "",
|
||||
PACKAGE_PROPERTY: "MolocoAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-moloco.git",
|
||||
PACKAGE_VERSION_PROPERTY: "4.1.0",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["9t245vhmpl"],
|
||||
},
|
||||
Flag.MYTARGET: {
|
||||
FLAG_PROPERTY: Flag.MYTARGET,
|
||||
TAG_PROPERTY: "mytarget",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:mytarget:5.27.3.0"],
|
||||
MAVEN_REPO_PROPERTY: "",
|
||||
PACKAGE_PROPERTY: "MyTargetAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-mytarget.git",
|
||||
PACKAGE_VERSION_PROPERTY: "5.36.0",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["n9x2a789qt", "r26jy69rpl"],
|
||||
},
|
||||
Flag.PANGLE: {
|
||||
FLAG_PROPERTY: Flag.PANGLE,
|
||||
TAG_PROPERTY: "pangle",
|
||||
DEPENDENCIES_PROPERTY: ["com.google.ads.mediation:pangle:7.6.0.5.0"],
|
||||
MAVEN_REPO_PROPERTY: "https://artifact.bytedance.com/repository/pangle/",
|
||||
PACKAGE_PROPERTY: "PangleAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-pangle.git",
|
||||
PACKAGE_VERSION_PROPERTY: "7.6.600",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["22mmun2rn5"],
|
||||
},
|
||||
Flag.UNITY: {
|
||||
FLAG_PROPERTY: Flag.UNITY,
|
||||
TAG_PROPERTY: "unity",
|
||||
DEPENDENCIES_PROPERTY: ["com.unity3d.ads:unity-ads:4.16.2", "com.google.ads.mediation:unity:4.16.3.0"],
|
||||
MAVEN_REPO_PROPERTY: "",
|
||||
PACKAGE_PROPERTY: "UnityAdapterTarget",
|
||||
PACKAGE_URL_PROPERTY: "https://github.com/googleads/googleads-mobile-ios-mediation-unity.git",
|
||||
PACKAGE_VERSION_PROPERTY: "4.16.4",
|
||||
SK_AD_NETWORK_IDS_PROPERTY: ["9nlqeag3gk", "hs6bdukanm", "9rd848q2bz", "22mmun2rn5", "f73kdq92p3", "tl55sbb4fm", "prcb7njmu6", "n9x2a789qt", "97r2b46745", "zmvfpc5aq8", "av6w8kgt66", "uw77j35x4d", "pwa73g5rt2", "e5fvkxwrpn", "klf5c3l5u5", "xga6mpmplv", "wg4vff78zm", "lr83yxwka7", "5tjdwbrq8w", "7ug5zh24hu", "glqzh8vgby", "feyaarzu9v", "6yxyv74ff7", "mqn7fxpca7", "vhf287vqwu", "g6gcrrvk4p", "v79kvwwj4g", "32z4fx6l9h", "2fnua5tdw4", "a8cz6cu7e5", "v72qych5uu", "a2p9lx4jpn", "5f5u5tfb26", "4fzdc2evr5", "5lm9lj6jb7", "4w7y6s5ca2", "3sh42y64q3", "4468km3ulz", "mj797d8u6f", "488r3q3dtq", "mlmmfzh3r3", "9t245vhmpl", "x44k69ngh6", "kbd757ywx3", "4dzt52r2t5", "k674qkevps", "294l99pt4k", "m8dbw4sv7c", "2u9pt9hc89", "p78axxw29g", "424m5254lk", "238da6jt44", "ydx93a7ass", "s39g8k73mm", "v9wttpbfk9", "44jx6755aq", "578prtvx9j", "yclnxrl5pm", "k6y4y55b64", "5a6flpkh64", "3rd42ekr43", "f38h382jlk", "cstr6suwn9", "ppxm28t8ap", "f7s53z58qe", "zq492l623r", "t38b2kh725", "mp6xlyr22a", "3qy4746246", "5l3tpt7t6e", "c6k4g5qg8m", "w9q455wk68", "4pfyvq9l8r", "wzmmz9fp6w", "8s468mfl3y"],
|
||||
},
|
||||
}
|
||||
|
||||
const MEDIATION_NETWORK_TAGS: Dictionary = {
|
||||
"applovin": Flag.APPLOVIN,
|
||||
"chartboost": Flag.CHARTBOOST,
|
||||
"dtexchange": Flag.DTEXCHANGE,
|
||||
"imobile": Flag.IMOBILE,
|
||||
"inmobi": Flag.INMOBI,
|
||||
"ironsource": Flag.IRONSOURCE,
|
||||
"liftoff": Flag.LIFTOFF,
|
||||
"line": Flag.LINE,
|
||||
"maio": Flag.MAIO,
|
||||
"meta": Flag.META,
|
||||
"mintegral": Flag.MINTEGRAL,
|
||||
"moloco": Flag.MOLOCO,
|
||||
"mytarget": Flag.MYTARGET,
|
||||
"pangle": Flag.PANGLE,
|
||||
"unity": Flag.UNITY,
|
||||
}
|
||||
|
||||
var flag: Flag
|
||||
var tag: String
|
||||
var android_dependencies: Array
|
||||
var android_custom_maven_repo: String
|
||||
var swift_package: String
|
||||
var swift_package_url: String
|
||||
var swift_package_version: String
|
||||
var sk_ad_network_ids: PackedStringArray
|
||||
|
||||
|
||||
func _init(a_data: Dictionary) -> void:
|
||||
flag = a_data[FLAG_PROPERTY]
|
||||
tag = a_data[TAG_PROPERTY]
|
||||
android_dependencies = a_data[DEPENDENCIES_PROPERTY]
|
||||
android_custom_maven_repo = a_data[MAVEN_REPO_PROPERTY]
|
||||
swift_package = a_data[PACKAGE_PROPERTY]
|
||||
swift_package_url = a_data[PACKAGE_URL_PROPERTY]
|
||||
swift_package_version = a_data[PACKAGE_VERSION_PROPERTY]
|
||||
sk_ad_network_ids = a_data[SK_AD_NETWORK_IDS_PROPERTY]
|
||||
|
||||
|
||||
func get_spm_dependency() -> SpmDependency:
|
||||
return SpmDependency.new({
|
||||
SpmDependency.URL_PROPERTY: swift_package_url,
|
||||
SpmDependency.VERSION_PROPERTY: swift_package_version,
|
||||
SpmDependency.PRODUCTS_PROPERTY: [swift_package],
|
||||
})
|
||||
|
||||
static func is_flag_enabled(a_value: int, a_flag: Flag) -> bool:
|
||||
return a_value & a_flag
|
||||
|
||||
|
||||
static func is_valid_tag(a_tag: String) -> bool:
|
||||
return MEDIATION_NETWORK_TAGS.has(a_tag)
|
||||
|
||||
|
||||
static func get_by_flag(a_flag: Flag) -> MediationNetwork:
|
||||
return MediationNetwork.new(MEDIATION_NETWORKS[a_flag])
|
||||
|
||||
|
||||
static func get_by_tag(a_tag: String) -> MediationNetwork:
|
||||
return get_by_flag(MEDIATION_NETWORK_TAGS[a_tag])
|
||||
|
||||
|
||||
static func get_all_enabled(a_value: int) -> Array[MediationNetwork]:
|
||||
var __enabled_networks: Array[MediationNetwork] = []
|
||||
|
||||
for __flag in Flag.values():
|
||||
if is_flag_enabled(a_value, __flag):
|
||||
__enabled_networks.append(get_by_flag(__flag))
|
||||
|
||||
return __enabled_networks
|
||||
|
||||
|
||||
static func get_all_enabled_tags(a_value: int) -> Array[String]:
|
||||
var __enabled_network_tags: Array[String] = []
|
||||
|
||||
for __flag in Flag.values():
|
||||
if is_flag_enabled(a_value, __flag):
|
||||
var __network: MediationNetwork = get_by_flag(__flag)
|
||||
__enabled_network_tags.append(__network.tag)
|
||||
|
||||
return __enabled_network_tags
|
||||
|
||||
|
||||
static func generate_sk_ad_network_plist(a_networks: Array[MediationNetwork]) -> String:
|
||||
var __sk_ad_ids_plist_content: String
|
||||
|
||||
var __unique_sk_network_ad_ids: Dictionary = { }
|
||||
|
||||
# Add Google-required SK Ad Network IDs
|
||||
for __network_id in GOOGLE_SK_AD_NETWORK_IDS:
|
||||
if not __unique_sk_network_ad_ids.has(__network_id):
|
||||
__unique_sk_network_ad_ids.set(__network_id, null)
|
||||
|
||||
# Add SK Ad Network IDs required by other networks
|
||||
for __network in a_networks:
|
||||
for __network_id in __network.sk_ad_network_ids:
|
||||
if not __unique_sk_network_ad_ids.has(__network_id):
|
||||
__unique_sk_network_ad_ids.set(__network_id, null)
|
||||
|
||||
for __network_id in __unique_sk_network_ad_ids.keys():
|
||||
__sk_ad_ids_plist_content += SK_AD_NETWORK_ITEM_FORMAT % __network_id
|
||||
|
||||
return SK_AD_NETWORK_ITEM_LIST_FORMAT % __sk_ad_ids_plist_content
|
||||
|
||||
|
||||
static func get_spm_dependencies(a_networks: Array[MediationNetwork]) -> Array[SpmDependency]:
|
||||
var __spm_dependencies: Array[SpmDependency] = []
|
||||
|
||||
for __network in a_networks:
|
||||
__spm_dependencies.append(__network.get_spm_dependency())
|
||||
|
||||
return __spm_dependencies
|
||||
|
||||
|
||||
static func generate_tag_list(a_networks: Array[MediationNetwork]) -> String:
|
||||
var __enabled_network_tags: PackedStringArray = []
|
||||
|
||||
for __network in a_networks:
|
||||
__enabled_network_tags.append(__network.tag)
|
||||
|
||||
return ",".join(__enabled_network_tags)
|
||||
@@ -0,0 +1 @@
|
||||
uid://devoemkicbhtc
|
||||
@@ -0,0 +1,32 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name NetworkExtras
|
||||
extends Resource
|
||||
|
||||
const NETWORK_TAG_PROPERTY := &"network_tag"
|
||||
const EXTRAS_PROPERTY := &"extras"
|
||||
|
||||
## Identifies the target ad network for the extras.
|
||||
@export var network_flag: MediationNetwork.Flag = MediationNetwork.Flag.APPLOVIN
|
||||
|
||||
## Dictionary holding one or more extras. Only keys of type String are allowed. Values can be String, int, or bool.
|
||||
@export var extras: Dictionary
|
||||
|
||||
|
||||
func get_raw_data() -> Dictionary:
|
||||
var __network: MediationNetwork = MediationNetwork.get_by_flag(network_flag)
|
||||
var __raw_data: Dictionary
|
||||
|
||||
__raw_data = {NETWORK_TAG_PROPERTY: __network.tag, EXTRAS_PROPERTY: extras}
|
||||
|
||||
return __raw_data
|
||||
|
||||
|
||||
static func build_raw_data_array(a_network_extras: Array[NetworkExtras]) -> Array:
|
||||
var __raw_data_array: Array[Dictionary] = []
|
||||
|
||||
for __network_extras in a_network_extras:
|
||||
__raw_data_array.append(__network_extras.get_raw_data())
|
||||
|
||||
return __raw_data_array
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxw2idjnk2d72
|
||||
@@ -0,0 +1,45 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name NetworkPrivacySettings
|
||||
extends RefCounted
|
||||
|
||||
const HAS_GDPR_CONSENT_PROPERTY := &"has_gdpr_consent"
|
||||
const IS_AGE_RESTRICTED_USER_PROPERTY := &"is_age_restricted_user"
|
||||
const HAS_CCPA_SALE_CONSENT_PROPERTY := &"has_ccpa_sale_consent"
|
||||
const ENABLED_NETWORKS_PROPERTY := &"enabled_networks"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
_data = {
|
||||
ENABLED_NETWORKS_PROPERTY: [],
|
||||
}
|
||||
|
||||
|
||||
func set_has_gdpr_consent(a_value: bool) -> NetworkPrivacySettings:
|
||||
_data[HAS_GDPR_CONSENT_PROPERTY] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func set_is_age_restricted_user(a_value: bool) -> NetworkPrivacySettings:
|
||||
_data[IS_AGE_RESTRICTED_USER_PROPERTY] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func set_has_ccpa_sale_consent(a_value: bool) -> NetworkPrivacySettings:
|
||||
_data[HAS_CCPA_SALE_CONSENT_PROPERTY] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func set_enabled_networks(a_value: Array[String]) -> NetworkPrivacySettings:
|
||||
if a_value == null:
|
||||
_data[ENABLED_NETWORKS_PROPERTY] = []
|
||||
else:
|
||||
_data[ENABLED_NETWORKS_PROPERTY] = a_value
|
||||
return self
|
||||
|
||||
|
||||
func get_raw_data() -> Dictionary:
|
||||
return _data
|
||||
@@ -0,0 +1 @@
|
||||
uid://b42fjtxon0ynl
|
||||
@@ -0,0 +1,46 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name ResponseInfo
|
||||
extends RefCounted
|
||||
|
||||
const ADAPTER_RESPONSES_PROPERTY := &"adapter_responses"
|
||||
const LOADED_ADAPTER_RESPONSE_PROPERTY := &"loaded_adapter_response"
|
||||
const ADAPTER_CLASS_NAME_PROPERTY := &"adapter_class_name"
|
||||
const NETWORK_TAG_PROPERTY := &"network_tag"
|
||||
const RESPONSE_ID_PROPERTY := &"response_id"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary):
|
||||
_data = a_data
|
||||
|
||||
|
||||
func get_adapter_responses() -> Array[AdapterResponseInfo]:
|
||||
var __responses: Array[AdapterResponseInfo] = []
|
||||
|
||||
for __response_dict in _data[ADAPTER_RESPONSES_PROPERTY]:
|
||||
__responses.append(AdapterResponseInfo.new(__response_dict))
|
||||
|
||||
return __responses
|
||||
|
||||
|
||||
func get_loaded_adapter_response() -> AdapterResponseInfo:
|
||||
return (
|
||||
AdapterResponseInfo.new(_data[LOADED_ADAPTER_RESPONSE_PROPERTY])
|
||||
if _data.has(LOADED_ADAPTER_RESPONSE_PROPERTY)
|
||||
else null
|
||||
)
|
||||
|
||||
|
||||
func get_adapter_class_name() -> String:
|
||||
return _data[ADAPTER_CLASS_NAME_PROPERTY] if _data.has(ADAPTER_CLASS_NAME_PROPERTY) else ""
|
||||
|
||||
|
||||
func get_network_tag() -> String:
|
||||
return _data[NETWORK_TAG_PROPERTY] if _data.has(NETWORK_TAG_PROPERTY) else ""
|
||||
|
||||
|
||||
func get_response_id() -> String:
|
||||
return _data[RESPONSE_ID_PROPERTY] if _data.has(RESPONSE_ID_PROPERTY) else ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3jib8n1rbnyv
|
||||
@@ -0,0 +1,22 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name RewardItem
|
||||
extends RefCounted
|
||||
|
||||
const AMOUNT_PROPERTY := &"amount"
|
||||
const TYPE_PROPERTY := &"type"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary):
|
||||
_data = a_data
|
||||
|
||||
|
||||
func get_amount() -> int:
|
||||
return _data[AMOUNT_PROPERTY]
|
||||
|
||||
|
||||
func get_type() -> String:
|
||||
return _data[TYPE_PROPERTY]
|
||||
@@ -0,0 +1 @@
|
||||
uid://cf0fkl05bgokb
|
||||
@@ -0,0 +1,33 @@
|
||||
#
|
||||
# © 2024-present https://github.com/cengiz-pz
|
||||
#
|
||||
class_name UserConsent
|
||||
extends RefCounted
|
||||
|
||||
enum Status {
|
||||
UNKNOWN,
|
||||
NOT_REQUIRED,
|
||||
REQUIRED,
|
||||
OBTAINED,
|
||||
}
|
||||
|
||||
var status: Status
|
||||
|
||||
|
||||
func _init(a_status_string: String = "") -> void:
|
||||
if a_status_string == "":
|
||||
status = Status.UNKNOWN
|
||||
else:
|
||||
status = string_to_status(a_status_string)
|
||||
|
||||
|
||||
func to_status_string() -> String:
|
||||
return Status.keys()[status]
|
||||
|
||||
|
||||
static func status_to_string(a_status: Status) -> String:
|
||||
return Status.keys()[a_status]
|
||||
|
||||
|
||||
static func string_to_status(a_string: String) -> Status:
|
||||
return Status.get(a_string)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcbs2sidgjetb
|
||||
@@ -0,0 +1,10 @@
|
||||
;
|
||||
; © 2024-present https://github.com/cengiz-pz
|
||||
;
|
||||
[plugin]
|
||||
|
||||
name="Admob"
|
||||
description="Allows displaying of AdMob ads."
|
||||
author="Cengiz"
|
||||
version="7.0"
|
||||
script="AdmobPlugin.gd"
|
||||
@@ -0,0 +1,29 @@
|
||||
#
|
||||
# © 2026-present https://github.com/<<GitHubUsername>>
|
||||
#
|
||||
|
||||
class_name GmpLogger extends Object
|
||||
|
||||
|
||||
static func log_error(a_description: String) -> void:
|
||||
push_error("%s" % [a_description])
|
||||
|
||||
|
||||
static func log_warn(a_description: String) -> void:
|
||||
push_warning("%s" % [a_description])
|
||||
|
||||
|
||||
static func log_info(a_description: String) -> void:
|
||||
print_rich("[color=lime]INFO: %s[/color]" % [a_description])
|
||||
|
||||
|
||||
static func error(a_plugin_name: String, a_description: String) -> void:
|
||||
log_error("%s: %s" % [a_plugin_name, a_description])
|
||||
|
||||
|
||||
static func warn(a_plugin_name: String, a_description: String) -> void:
|
||||
log_warn("%s: %s" % [a_plugin_name, a_description])
|
||||
|
||||
|
||||
static func info(a_plugin_name: String, a_description: String) -> void:
|
||||
log_info("%s: %s" % [a_plugin_name, a_description])
|
||||
@@ -0,0 +1 @@
|
||||
uid://78axiycs2dmy
|
||||
@@ -0,0 +1,48 @@
|
||||
#
|
||||
# © 2026-present https://github.com/<<GitHubUsername>>
|
||||
#
|
||||
|
||||
class_name SpmDependency extends RefCounted
|
||||
|
||||
const URL_PROPERTY := &"url"
|
||||
const VERSION_PROPERTY := &"version"
|
||||
const PRODUCTS_PROPERTY := &"products"
|
||||
|
||||
var _data: Dictionary
|
||||
|
||||
|
||||
func _init(a_data: Dictionary = {}):
|
||||
_data = a_data
|
||||
|
||||
|
||||
func get_url() -> String:
|
||||
return _data[URL_PROPERTY]
|
||||
|
||||
|
||||
func set_url(a_url: String) -> void:
|
||||
_data[URL_PROPERTY] = a_url
|
||||
|
||||
|
||||
func get_version() -> String:
|
||||
return _data[VERSION_PROPERTY]
|
||||
|
||||
|
||||
func set_version(a_version: String) -> void:
|
||||
_data[VERSION_PROPERTY] = a_version
|
||||
|
||||
|
||||
func get_products() -> Array:
|
||||
return _data[PRODUCTS_PROPERTY]
|
||||
|
||||
|
||||
func set_products(a_products: Array) -> void:
|
||||
_data[PRODUCTS_PROPERTY] = a_products
|
||||
|
||||
|
||||
func format_to_string() -> String:
|
||||
var __result: String = ""
|
||||
|
||||
for __product in get_products():
|
||||
__result += "%s@%s (%s) " % [__product, get_version(), get_url()]
|
||||
|
||||
return __result
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxpvam6fnixki
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Blake Bukowsky
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Tools
|
||||
|
||||
Godot editor plugin that exposes **43 tools across 12 namespaces** (scene edits, signal wiring, resource creation, reference validation, animation, headless test runs, etc.) over MCP so coding agents can operate through the editor's real APIs instead of hand-editing `.tscn` / `.tres` / `project.godot` as text.
|
||||
|
||||
Works with any MCP-capable client — Claude Code, Claude Desktop, Cursor, Cline, Windsurf, Continue, Zed.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Enable this plugin: *Project → Project Settings → Plugins* → tick **Agent Tools**.
|
||||
2. Confirm the Output panel shows `[agent_tools] listening on 127.0.0.1:9920`.
|
||||
3. Configure your MCP client to run the bridge — see the [full setup guide](https://github.com/BlakeBukowsky/GodotTools#configure-your-agent) for per-client config paths.
|
||||
|
||||
Simplest user-scoped config (for Claude Code, Cursor, Windsurf, etc. — different config file per client):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"godot-agent-tools": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "godot-agent-tools-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Full documentation, troubleshooting, and tool catalog: **https://github.com/BlakeBukowsky/GodotTools**
|
||||
|
||||
## Raw TCP protocol (if you want to skip MCP)
|
||||
|
||||
Line-delimited JSON-RPC on `127.0.0.1:9920`:
|
||||
|
||||
```json
|
||||
{"id": 1, "method": "scene.current", "params": {}}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{"id": 1, "result": {"open": false}}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,187 @@
|
||||
# Auto-run driver for run.scene_headless (input simulation, multi-screenshot,
|
||||
# state dump, deterministic seed). Reads a per-call config from
|
||||
# res://.godot/agent_tools/headless/config.json, instances the target scene,
|
||||
# injects scripted input per frame, captures screenshots at specified frames,
|
||||
# optionally writes a final-state JSON dump, then quits.
|
||||
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
const CONFIG_PATH := "res://.godot/agent_tools/headless/config.json"
|
||||
const Coerce := preload("res://addons/agent_tools/tools/_coerce.gd")
|
||||
|
||||
var _input_script: Array = []
|
||||
var _screenshots: Array = [] # [{frame, path}, ...]
|
||||
var _state_dump: bool = false
|
||||
var _state_dump_path: String = ""
|
||||
var _quit_frame: int = 120
|
||||
var _frame: int = 0
|
||||
var _target_inst: Node = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
var f := FileAccess.open(CONFIG_PATH, FileAccess.READ)
|
||||
if f == null:
|
||||
push_error("[headless_driver] config not found at %s" % CONFIG_PATH)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
var parsed = JSON.parse_string(f.get_as_text())
|
||||
f.close()
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
push_error("[headless_driver] malformed config")
|
||||
get_tree().quit(2)
|
||||
return
|
||||
|
||||
# Seed the global RNG before instancing so the target scene's randomness
|
||||
# (randi/randf in _ready, etc.) is deterministic per-seed.
|
||||
if parsed.has("seed"):
|
||||
seed(int(parsed.seed))
|
||||
|
||||
_input_script = parsed.get("input_script", [])
|
||||
_screenshots = parsed.get("screenshots", [])
|
||||
_state_dump = bool(parsed.get("state_dump", false))
|
||||
_state_dump_path = str(parsed.get("state_dump_path", ""))
|
||||
_quit_frame = int(parsed.get("quit_frame", 120))
|
||||
var target_scene_path: String = parsed.get("target_scene", "")
|
||||
|
||||
if target_scene_path == "":
|
||||
push_error("[headless_driver] config missing 'target_scene'")
|
||||
get_tree().quit(2)
|
||||
return
|
||||
var packed = load(target_scene_path)
|
||||
if packed == null:
|
||||
push_error("[headless_driver] failed to load target %s" % target_scene_path)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
_target_inst = packed.instantiate()
|
||||
add_child(_target_inst)
|
||||
print("[headless_driver] driving %s for %d frames, %d events, %d screenshots%s" %
|
||||
[target_scene_path, _quit_frame, _input_script.size(), _screenshots.size(),
|
||||
", state_dump=on" if _state_dump else ""])
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# 1. Inject input events scheduled for this frame.
|
||||
for spec in _input_script:
|
||||
if int(spec.get("frame", -1)) == _frame:
|
||||
_inject(spec)
|
||||
|
||||
# 2. Capture screenshots scheduled for this frame.
|
||||
for shot in _screenshots:
|
||||
if int(shot.get("frame", -1)) == _frame:
|
||||
_capture(str(shot.get("path", "")))
|
||||
|
||||
if _frame >= _quit_frame:
|
||||
if _state_dump and _state_dump_path != "":
|
||||
_write_state_dump()
|
||||
print("[headless_driver] done at frame %d" % _frame)
|
||||
get_tree().quit(0)
|
||||
return
|
||||
_frame += 1
|
||||
|
||||
|
||||
func _inject(spec: Dictionary) -> void:
|
||||
match spec.get("type", ""):
|
||||
"action_press":
|
||||
Input.action_press(spec.get("action", ""), float(spec.get("strength", 1.0)))
|
||||
"action_release":
|
||||
Input.action_release(spec.get("action", ""))
|
||||
"action_tap":
|
||||
Input.action_press(spec.get("action", ""), float(spec.get("strength", 1.0)))
|
||||
Input.action_release(spec.get("action", ""))
|
||||
"key":
|
||||
var ek := InputEventKey.new()
|
||||
var kc = spec.get("keycode", 0)
|
||||
if kc is String:
|
||||
ek.physical_keycode = OS.find_keycode_from_string(kc)
|
||||
else:
|
||||
ek.physical_keycode = int(kc)
|
||||
ek.pressed = bool(spec.get("pressed", true))
|
||||
Input.parse_input_event(ek)
|
||||
"mouse_click":
|
||||
var pos = spec.get("position", [0, 0])
|
||||
var v := Vector2(pos[0], pos[1])
|
||||
var button := int(spec.get("button", 1))
|
||||
var down := InputEventMouseButton.new()
|
||||
down.button_index = button
|
||||
down.pressed = true
|
||||
down.position = v
|
||||
down.global_position = v
|
||||
Input.parse_input_event(down)
|
||||
var up := InputEventMouseButton.new()
|
||||
up.button_index = button
|
||||
up.pressed = false
|
||||
up.position = v
|
||||
up.global_position = v
|
||||
Input.parse_input_event(up)
|
||||
"mouse_motion":
|
||||
var pos = spec.get("position", [0, 0])
|
||||
var em := InputEventMouseMotion.new()
|
||||
em.position = Vector2(pos[0], pos[1])
|
||||
em.global_position = em.position
|
||||
Input.parse_input_event(em)
|
||||
_:
|
||||
push_warning("[headless_driver] unknown event type: %s" % spec.get("type", "<missing>"))
|
||||
|
||||
|
||||
func _capture(path: String) -> void:
|
||||
if path == "":
|
||||
return
|
||||
var vp := get_tree().root.get_viewport()
|
||||
if vp == null:
|
||||
push_warning("[headless_driver] no root viewport")
|
||||
return
|
||||
var tex := vp.get_texture()
|
||||
if tex == null:
|
||||
push_warning("[headless_driver] viewport has no texture (running under dummy renderer?)")
|
||||
return
|
||||
var img := tex.get_image()
|
||||
if img == null or img.is_empty():
|
||||
push_warning("[headless_driver] failed to capture image at frame %d" % _frame)
|
||||
return
|
||||
var dir := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir):
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
var err := img.save_png(path)
|
||||
if err != OK:
|
||||
push_warning("[headless_driver] save_png failed: %d" % err)
|
||||
else:
|
||||
print("[headless_driver] frame %d -> %s" % [_frame, path])
|
||||
|
||||
|
||||
func _write_state_dump() -> void:
|
||||
if _target_inst == null:
|
||||
return
|
||||
var snapshot := _node_snapshot(_target_inst, _target_inst)
|
||||
var f := FileAccess.open(_state_dump_path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_warning("[headless_driver] failed to open state dump path: %s" % _state_dump_path)
|
||||
return
|
||||
f.store_string(JSON.stringify(snapshot, " "))
|
||||
f.close()
|
||||
print("[headless_driver] state dump -> %s" % _state_dump_path)
|
||||
|
||||
|
||||
# Mirrors scene.inspect's shape, plus a small selection of commonly-useful
|
||||
# properties. Kept narrow on purpose — full get_property_list() dumps balloon.
|
||||
static func _node_snapshot(node: Node, scene_root: Node) -> Dictionary:
|
||||
var d := {
|
||||
"name": String(node.name),
|
||||
"class": node.get_class(),
|
||||
"node_path": "." if node == scene_root else String(scene_root.get_path_to(node)),
|
||||
"script": "",
|
||||
"properties": {},
|
||||
"children": [],
|
||||
}
|
||||
var scr := node.get_script() as Script
|
||||
if scr:
|
||||
d.script = scr.resource_path
|
||||
# Common props worth verifying, if present on this node type.
|
||||
for prop_name in ["visible", "position", "global_position", "size", "text",
|
||||
"value", "modulate", "color", "rotation", "scale", "disabled", "pressed"]:
|
||||
if prop_name in node:
|
||||
d.properties[prop_name] = Coerce.to_json(node.get(prop_name))
|
||||
for child in node.get_children():
|
||||
if child.owner == scene_root or child == scene_root:
|
||||
d.children.append(_node_snapshot(child, scene_root))
|
||||
return d
|
||||
@@ -0,0 +1 @@
|
||||
uid://iko41fhoo3mm
|
||||
@@ -0,0 +1,6 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/agent_tools/headless/driver.gd" id="1"]
|
||||
|
||||
[node name="HeadlessDriver" type="Node"]
|
||||
script = ExtResource("1")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,40 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://d2wklut1fj5ed"
|
||||
path="res://.godot/imported/icon.png-381bc5920e04d060db359f1234d8c8b0.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/agent_tools/icon.png"
|
||||
dest_files=["res://.godot/imported/icon.png-381bc5920e04d060db359f1234d8c8b0.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
@@ -0,0 +1,9 @@
|
||||
[plugin]
|
||||
|
||||
name="Agent Tools"
|
||||
description="70 tools + 6 MCP Resources for coding agents to edit scenes, wire signals, create resources, and more through Godot's real APIs instead of hand-editing .tscn/.tres files. If you haven't already, configure your MCP client (Claude Code, Cursor, Cline, etc.) per the README: https://github.com/BlakeBukowsky/GodotTools"
|
||||
author="Blake Bukowsky"
|
||||
version="0.3.1"
|
||||
license="MIT"
|
||||
script="plugin.gd"
|
||||
icon="icon.png"
|
||||
@@ -0,0 +1,132 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
const Server := preload("res://addons/agent_tools/server.gd")
|
||||
const Registry := preload("res://addons/agent_tools/registry.gd")
|
||||
|
||||
const DEFAULT_PORT := 9920
|
||||
const PORT_RANGE_END := 9929 # scan 9920..9929 inclusive
|
||||
const PORT_SETTING := "agent_tools/port"
|
||||
const BRIDGE_AUTOLOAD_NAME := "_MCPGameBridge"
|
||||
const BRIDGE_AUTOLOAD_PATH := "res://addons/agent_tools/runtime/game_bridge.gd"
|
||||
|
||||
const DEFAULT_INTERFACE := "127.0.0.1"
|
||||
const INTERFACE_SETTING := "agent_tools/interface"
|
||||
|
||||
var _server: Server
|
||||
var _registry: Registry
|
||||
var _bound_port: int = -1
|
||||
var _session_file_path: String = ""
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
_registry = Registry.new()
|
||||
_server = Server.new(_registry)
|
||||
add_child(_server)
|
||||
|
||||
# Interface resolution
|
||||
# Defaults to 127.0.0.1, override with project.godot agent_tools/interface
|
||||
var forced_interface := DEFAULT_INTERFACE
|
||||
if ProjectSettings.has_setting(INTERFACE_SETTING):
|
||||
forced_interface = ProjectSettings.get_setting(INTERFACE_SETTING)
|
||||
|
||||
# Port resolution:
|
||||
# 1. If 'agent_tools/port' is set in project.godot, honor it strictly (fail if busy).
|
||||
# 2. Otherwise try DEFAULT_PORT..PORT_RANGE_END and use the first free one.
|
||||
# This lets multiple Godot editors coexist: first-to-start takes 9920, second takes 9921, etc.
|
||||
var forced_port := -1
|
||||
if ProjectSettings.has_setting(PORT_SETTING):
|
||||
forced_port = int(ProjectSettings.get_setting(PORT_SETTING))
|
||||
|
||||
if forced_port != -1:
|
||||
if _server.start(forced_port, forced_interface):
|
||||
_bound_port = forced_port
|
||||
else:
|
||||
push_error("[agent_tools] configured port %s:%d is busy. Either close the other process or change 'agent_tools/port' in project.godot." % [forced_interface, forced_port])
|
||||
else:
|
||||
for p in range(DEFAULT_PORT, PORT_RANGE_END + 1):
|
||||
if _server.start(p, forced_interface):
|
||||
_bound_port = p
|
||||
break
|
||||
if _bound_port == -1:
|
||||
push_error("[agent_tools] all ports %d-%d are busy on %s. Close other Godot editors running agent_tools, or set 'agent_tools/port' in project.godot." % [DEFAULT_PORT, PORT_RANGE_END, forced_interface])
|
||||
|
||||
if _bound_port != -1:
|
||||
print("[agent_tools] listening on %s:%d" % [forced_interface, _bound_port])
|
||||
print("[agent_tools] If you haven't already, configure your MCP client (Claude Code, Cursor, Cline, etc.) to use these tools.")
|
||||
print("[agent_tools] Setup guide: https://github.com/BlakeBukowsky/GodotTools#configure-your-agent")
|
||||
_write_session_registry()
|
||||
|
||||
# Register the runtime bridge autoload so editor.game_screenshot / logs.read
|
||||
# can reach the running game. add_autoload_singleton() writes an entry to
|
||||
# project.godot; remove_autoload_singleton() in _exit_tree cleans it up.
|
||||
if not ProjectSettings.has_setting("autoload/" + BRIDGE_AUTOLOAD_NAME):
|
||||
add_autoload_singleton(BRIDGE_AUTOLOAD_NAME, BRIDGE_AUTOLOAD_PATH)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
_remove_session_registry()
|
||||
if _server:
|
||||
_server.stop()
|
||||
_server.queue_free()
|
||||
_server = null
|
||||
_registry = null
|
||||
_bound_port = -1
|
||||
if ProjectSettings.has_setting("autoload/" + BRIDGE_AUTOLOAD_NAME):
|
||||
remove_autoload_singleton(BRIDGE_AUTOLOAD_NAME)
|
||||
|
||||
|
||||
# Session registry is a per-PID JSON file under a cross-project, user-scoped
|
||||
# directory. The MCP shim reads these on startup so `session.list` can discover
|
||||
# every running Godot editor + which port to talk to each.
|
||||
func _write_session_registry() -> void:
|
||||
var dir_path := _session_registry_dir()
|
||||
if dir_path == "":
|
||||
return
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
push_warning("[agent_tools] could not create session registry dir: %s" % dir_path)
|
||||
return
|
||||
var pid := OS.get_process_id()
|
||||
_session_file_path = dir_path.path_join("%d.json" % pid)
|
||||
|
||||
var project_name := ""
|
||||
if ProjectSettings.has_setting("application/config/name"):
|
||||
project_name = str(ProjectSettings.get_setting("application/config/name"))
|
||||
var version := Engine.get_version_info()
|
||||
|
||||
var entry := {
|
||||
"pid": pid,
|
||||
"port": _bound_port,
|
||||
"project_path": ProjectSettings.globalize_path("res://"),
|
||||
"project_name": project_name,
|
||||
"godot_version": "%d.%d.%d-%s" % [version.major, version.minor, version.patch, version.status],
|
||||
"started_at_unix": int(Time.get_unix_time_from_system()),
|
||||
}
|
||||
var f := FileAccess.open(_session_file_path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_warning("[agent_tools] could not write session file: %s" % _session_file_path)
|
||||
return
|
||||
f.store_string(JSON.stringify(entry, " "))
|
||||
f.close()
|
||||
|
||||
|
||||
func _remove_session_registry() -> void:
|
||||
if _session_file_path == "":
|
||||
return
|
||||
if FileAccess.file_exists(_session_file_path):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(_session_file_path)
|
||||
_session_file_path = ""
|
||||
|
||||
|
||||
# Cross-project location: <user home>/.godot-agent-tools/sessions/. Chosen to
|
||||
# sit outside any single project's res:// or user:// tree.
|
||||
func _session_registry_dir() -> String:
|
||||
var home := OS.get_environment("USERPROFILE")
|
||||
if home == "":
|
||||
home = OS.get_environment("HOME")
|
||||
if home == "":
|
||||
return ""
|
||||
return home.path_join(".godot-agent-tools").path_join("sessions")
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkvb64lq51xyg
|
||||
@@ -0,0 +1,129 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
const SceneTools := preload("res://addons/agent_tools/tools/scene_tools.gd")
|
||||
const SignalTools := preload("res://addons/agent_tools/tools/signal_tools.gd")
|
||||
const ScriptTools := preload("res://addons/agent_tools/tools/script_tools.gd")
|
||||
const ResourceTools := preload("res://addons/agent_tools/tools/resource_tools.gd")
|
||||
const RefsTools := preload("res://addons/agent_tools/tools/refs_tools.gd")
|
||||
const ProjectTools := preload("res://addons/agent_tools/tools/project_tools.gd")
|
||||
const EditorTools := preload("res://addons/agent_tools/tools/editor_tools.gd")
|
||||
const DocsTools := preload("res://addons/agent_tools/tools/docs_tools.gd")
|
||||
const InputTools := preload("res://addons/agent_tools/tools/input_tools.gd")
|
||||
const RunTools := preload("res://addons/agent_tools/tools/run_tools.gd")
|
||||
const FsTools := preload("res://addons/agent_tools/tools/fs_tools.gd")
|
||||
const AnimationTools := preload("res://addons/agent_tools/tools/animation_tools.gd")
|
||||
const UserFsTools := preload("res://addons/agent_tools/tools/user_fs_tools.gd")
|
||||
const PerformanceTools := preload("res://addons/agent_tools/tools/performance_tools.gd")
|
||||
const TestTools := preload("res://addons/agent_tools/tools/test_tools.gd")
|
||||
const ClientTools := preload("res://addons/agent_tools/tools/client_tools.gd")
|
||||
const PhysicsTools := preload("res://addons/agent_tools/tools/physics_tools.gd")
|
||||
const ThemeTools := preload("res://addons/agent_tools/tools/theme_tools.gd")
|
||||
|
||||
|
||||
# Returns {"data": <any>} on success, {"error": {"code": int, "message": str}} on failure.
|
||||
func dispatch(method: String, params: Dictionary) -> Dictionary:
|
||||
match method:
|
||||
"scene.new": return SceneTools.new_scene(params)
|
||||
"scene.add_node": return SceneTools.add_node(params)
|
||||
"scene.instance_packed": return SceneTools.instance_packed(params)
|
||||
"scene.duplicate_node": return SceneTools.duplicate_node(params)
|
||||
"scene.remove_node": return SceneTools.remove_node(params)
|
||||
"scene.reparent": return SceneTools.reparent(params)
|
||||
"scene.set_property": return SceneTools.set_property(params)
|
||||
"scene.get_property": return SceneTools.get_property(params)
|
||||
"scene.call_method": return SceneTools.call_method(params)
|
||||
"scene.build_tree": return SceneTools.build_tree(params)
|
||||
"scene.open": return SceneTools.open_scene(params)
|
||||
"scene.save": return SceneTools.save_scene(params)
|
||||
"scene.current": return SceneTools.current(params)
|
||||
"scene.inspect": return SceneTools.inspect(params)
|
||||
"scene.capture_screenshot": return SceneTools.capture_screenshot(params)
|
||||
"signal.connect": return SignalTools.connect_signal(params)
|
||||
"signal.disconnect": return SignalTools.disconnect_signal(params)
|
||||
"signal.list": return SignalTools.list_signals(params)
|
||||
"script.create": return ScriptTools.create(params)
|
||||
"script.attach": return ScriptTools.attach(params)
|
||||
"script.patch": return ScriptTools.patch(params)
|
||||
"resource.create": return ResourceTools.create(params)
|
||||
"resource.set_property": return ResourceTools.set_property(params)
|
||||
"resource.call_method": return ResourceTools.call_method(params)
|
||||
"refs.validate_project": return RefsTools.validate_project(params)
|
||||
"refs.find_usages": return RefsTools.find_usages(params)
|
||||
"refs.rename": return RefsTools.rename(params)
|
||||
"refs.rename_class": return RefsTools.rename_class(params)
|
||||
"project.get_setting": return ProjectTools.get_setting(params)
|
||||
"project.set_setting": return ProjectTools.set_setting(params)
|
||||
"autoload.add": return ProjectTools.autoload_add(params)
|
||||
"autoload.remove": return ProjectTools.autoload_remove(params)
|
||||
"autoload.list": return ProjectTools.autoload_list(params)
|
||||
"editor.reload_filesystem": return EditorTools.reload_filesystem(params)
|
||||
"editor.save_all_scenes": return EditorTools.save_all_scenes(params)
|
||||
"editor.state": return EditorTools.state(params)
|
||||
"editor.selection_get": return EditorTools.selection_get(params)
|
||||
"editor.selection_set": return EditorTools.selection_set(params)
|
||||
"editor.game_screenshot": return EditorTools.game_screenshot(params)
|
||||
"logs.read": return EditorTools.logs_read(params)
|
||||
"logs.clear": return EditorTools.logs_clear(params)
|
||||
"docs.class_ref": return DocsTools.class_ref(params)
|
||||
"input_map.add_action": return InputTools.add_action(params)
|
||||
"input_map.add_event": return InputTools.add_event(params)
|
||||
"input_map.list": return InputTools.list_actions(params)
|
||||
"input_map.remove_action": return InputTools.remove_action(params)
|
||||
"input_map.remove_event": return InputTools.remove_event(params)
|
||||
"run.scene_headless": return RunTools.scene_headless(params)
|
||||
"fs.list": return FsTools.list(params)
|
||||
"fs.read_text": return FsTools.read_text(params)
|
||||
"fs.write_text": return FsTools.write_text(params)
|
||||
"user_fs.read": return UserFsTools.read(params)
|
||||
"user_fs.list": return UserFsTools.list(params)
|
||||
"animation.list": return AnimationTools.list_animations(params)
|
||||
"animation.add_animation": return AnimationTools.add_animation(params)
|
||||
"animation.remove_animation": return AnimationTools.remove_animation(params)
|
||||
"animation.add_value_track": return AnimationTools.add_value_track(params)
|
||||
"performance.monitors": return PerformanceTools.monitors(params)
|
||||
"test.run": return TestTools.run(params)
|
||||
"client.list": return ClientTools.list_clients(params)
|
||||
"client.configure": return ClientTools.configure(params)
|
||||
"client.remove": return ClientTools.remove(params)
|
||||
"physics.autofit_collision_shape_2d": return PhysicsTools.autofit_collision_shape_2d(params)
|
||||
"theme.set_color": return ThemeTools.set_color(params)
|
||||
"theme.set_constant": return ThemeTools.set_constant(params)
|
||||
"theme.set_font_size": return ThemeTools.set_font_size(params)
|
||||
"theme.set_stylebox_flat": return ThemeTools.set_stylebox_flat(params)
|
||||
"batch.execute": return batch_execute(params)
|
||||
_:
|
||||
return {"error": {"code": -32601, "message": "method not found: %s" % method}}
|
||||
|
||||
|
||||
# batch.execute — run multiple tool calls in one round trip.
|
||||
# Params: {calls: [{method, params?}, ...], stop_on_error?: false}
|
||||
# Returns: {results: [{method, ok, data|error, index}, ...]}
|
||||
func batch_execute(params: Dictionary) -> Dictionary:
|
||||
var calls: Array = params.get("calls", [])
|
||||
var stop_on_error: bool = params.get("stop_on_error", false)
|
||||
if calls.is_empty():
|
||||
return {"error": {"code": -32602, "message": "missing 'calls' (array of {method, params})"}}
|
||||
|
||||
var results: Array = []
|
||||
for i in calls.size():
|
||||
var entry = calls[i]
|
||||
if typeof(entry) != TYPE_DICTIONARY or not entry.has("method"):
|
||||
results.append({"index": i, "ok": false, "error": "each call needs {method, params?}"})
|
||||
if stop_on_error:
|
||||
break
|
||||
continue
|
||||
var sub_method: String = entry.method
|
||||
var sub_params: Dictionary = entry.get("params", {})
|
||||
var sub_result: Dictionary = dispatch(sub_method, sub_params)
|
||||
var row := {"index": i, "method": sub_method}
|
||||
if sub_result.has("error"):
|
||||
row["ok"] = false
|
||||
row["error"] = sub_result.error
|
||||
else:
|
||||
row["ok"] = true
|
||||
row["data"] = sub_result.get("data")
|
||||
results.append(row)
|
||||
if stop_on_error and not row.ok:
|
||||
break
|
||||
return {"data": {"results": results, "count": results.size(), "requested": calls.size()}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5pe4omwjkjgo
|
||||
@@ -0,0 +1,31 @@
|
||||
# Custom Logger that forwards every captured message to the game bridge's
|
||||
# log buffer. Has to live in its own top-level file — nested classes don't
|
||||
# reliably register Logger's virtual overrides (_log_error / _log_message).
|
||||
|
||||
extends Logger
|
||||
|
||||
var _bridge: Node
|
||||
|
||||
|
||||
func _init(bridge: Node) -> void:
|
||||
_bridge = bridge
|
||||
|
||||
|
||||
func _log_error(function: String, file: String, line: int, code: String, rationale: String, editor_notify: bool, error_type: int, script_backtraces: Array) -> void:
|
||||
# error_type is Logger.ErrorType: 0=ERROR, 1=WARNING, 2=SCRIPT, 3=SHADER.
|
||||
var level: String
|
||||
match error_type:
|
||||
1: level = "WARNING"
|
||||
2: level = "SCRIPT"
|
||||
3: level = "SHADER"
|
||||
_: level = "ERROR"
|
||||
var msg: String = rationale if rationale != "" else code
|
||||
if file != "" and line > 0:
|
||||
msg += " (at %s:%d in %s)" % [file, line, function]
|
||||
if _bridge and _bridge.has_method("log_entry"):
|
||||
_bridge.log_entry(level, msg)
|
||||
|
||||
|
||||
func _log_message(message: String, error: bool) -> void:
|
||||
if _bridge and _bridge.has_method("log_entry"):
|
||||
_bridge.log_entry("ERROR" if error else "INFO", message.strip_edges())
|
||||
@@ -0,0 +1 @@
|
||||
uid://wosqs8dlh1vx
|
||||
@@ -0,0 +1,134 @@
|
||||
# MCPGameBridge — runtime autoload registered by the Agent Tools plugin.
|
||||
#
|
||||
# Bridges the editor-side agent tools to the *running* game via a file-based
|
||||
# request/response channel under res://.godot/agent_tools/bridge/. Currently:
|
||||
# - screenshot: capture the running viewport, save to a path
|
||||
# - logs: append print/push_error/push_warning output to a buffer
|
||||
#
|
||||
# This script ONLY runs in the game process (checks Engine.is_editor_hint()
|
||||
# on startup and disables itself in the editor). The editor plugin registers
|
||||
# it via add_autoload_singleton() on _enter_tree and removes it on _exit_tree.
|
||||
|
||||
extends Node
|
||||
|
||||
const BridgeLogger := preload("res://addons/agent_tools/runtime/bridge_logger.gd")
|
||||
const BRIDGE_DIR := "res://.godot/agent_tools/bridge"
|
||||
const REQUEST_PATH := BRIDGE_DIR + "/request.json"
|
||||
const RESPONSE_PATH := BRIDGE_DIR + "/response.json"
|
||||
const LOGS_PATH := BRIDGE_DIR + "/logs.json"
|
||||
const POLL_INTERVAL_SEC := 0.05 # 20 Hz — cheap enough, responsive enough
|
||||
const LOG_BUFFER_MAX := 500
|
||||
|
||||
var _timer: Timer
|
||||
var _log_buffer: Array = []
|
||||
var _initialized := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if Engine.is_editor_hint():
|
||||
# Tool mode — don't do anything inside the editor. The bridge only
|
||||
# makes sense in the running game.
|
||||
return
|
||||
_initialized = true
|
||||
_ensure_bridge_dir()
|
||||
# Poll for editor requests on a timer so we don't eat a _process callback.
|
||||
_timer = Timer.new()
|
||||
_timer.wait_time = POLL_INTERVAL_SEC
|
||||
_timer.one_shot = false
|
||||
_timer.autostart = true
|
||||
_timer.timeout.connect(_poll_request)
|
||||
add_child(_timer)
|
||||
# Attach our custom logger to capture print / push_error / push_warning
|
||||
# from the game and mirror them to the log buffer file so logs.read can
|
||||
# pick them up from the editor side.
|
||||
var logger := BridgeLogger.new(self)
|
||||
OS.add_logger(logger)
|
||||
|
||||
|
||||
func _ensure_bridge_dir() -> void:
|
||||
if not DirAccess.dir_exists_absolute(BRIDGE_DIR):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.make_dir_recursive_absolute(BRIDGE_DIR)
|
||||
|
||||
|
||||
# Called by MCPBridgeLogger whenever the game writes to stdout/stderr.
|
||||
func log_entry(level: String, message: String) -> void:
|
||||
_log_buffer.append({
|
||||
"level": level,
|
||||
"message": message,
|
||||
"time_ms": Time.get_ticks_msec(),
|
||||
})
|
||||
if _log_buffer.size() > LOG_BUFFER_MAX:
|
||||
_log_buffer = _log_buffer.slice(_log_buffer.size() - LOG_BUFFER_MAX)
|
||||
# Flush to disk so editor-side logs.read sees it. Debounced by poll interval
|
||||
# in practice because each log call flushes but writes are cheap.
|
||||
_flush_logs()
|
||||
|
||||
|
||||
func _flush_logs() -> void:
|
||||
var f := FileAccess.open(LOGS_PATH, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return
|
||||
f.store_string(JSON.stringify(_log_buffer))
|
||||
f.close()
|
||||
|
||||
|
||||
func _poll_request() -> void:
|
||||
if not FileAccess.file_exists(REQUEST_PATH):
|
||||
return
|
||||
var f := FileAccess.open(REQUEST_PATH, FileAccess.READ)
|
||||
if f == null:
|
||||
return
|
||||
var body := f.get_as_text()
|
||||
f.close()
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(REQUEST_PATH)
|
||||
|
||||
var parsed = JSON.parse_string(body)
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
_write_response({"id": "", "error": "malformed request"})
|
||||
return
|
||||
var req_id: String = parsed.get("id", "")
|
||||
match parsed.get("type", ""):
|
||||
"screenshot":
|
||||
_handle_screenshot(req_id, parsed)
|
||||
_:
|
||||
_write_response({"id": req_id, "error": "unknown request type: %s" % parsed.get("type", "<missing>")})
|
||||
|
||||
|
||||
func _handle_screenshot(req_id: String, req: Dictionary) -> void:
|
||||
var output_path: String = req.get("output", "")
|
||||
if output_path == "":
|
||||
_write_response({"id": req_id, "error": "missing 'output'"})
|
||||
return
|
||||
var vp := get_viewport()
|
||||
if vp == null:
|
||||
_write_response({"id": req_id, "error": "no viewport available"})
|
||||
return
|
||||
var tex := vp.get_texture()
|
||||
if tex == null:
|
||||
_write_response({"id": req_id, "error": "viewport has no texture"})
|
||||
return
|
||||
var img := tex.get_image()
|
||||
if img == null or img.is_empty():
|
||||
_write_response({"id": req_id, "error": "failed to capture image"})
|
||||
return
|
||||
var dir := output_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.make_dir_recursive_absolute(dir)
|
||||
var save_err := img.save_png(output_path)
|
||||
if save_err != OK:
|
||||
_write_response({"id": req_id, "error": "save_png failed: %d" % save_err})
|
||||
return
|
||||
_write_response({"id": req_id, "path": output_path})
|
||||
|
||||
|
||||
func _write_response(payload: Dictionary) -> void:
|
||||
var f := FileAccess.open(RESPONSE_PATH, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return
|
||||
f.store_string(JSON.stringify(payload))
|
||||
f.close()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cds186n8uv4f0
|
||||
@@ -0,0 +1,125 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
# Line-delimited JSON-RPC over TCP on localhost.
|
||||
# One request per line: {"id": <any>, "method": "<name>", "params": {...}}
|
||||
# One response per line: {"id": <echoed>, "result": <data>} OR {"id": ..., "error": {"code": int, "message": str, "data"?: any}}
|
||||
|
||||
const MAX_MESSAGE_BYTES := 1_048_576 # 1 MiB per line — safety cap
|
||||
|
||||
var _tcp: TCPServer
|
||||
var _clients: Array = [] # Array of {peer: StreamPeerTCP, buf: PackedByteArray}
|
||||
var _registry
|
||||
|
||||
|
||||
func _init(registry) -> void:
|
||||
_registry = registry
|
||||
|
||||
|
||||
func start(port: int, interface: String = "127.0.0.1") -> bool:
|
||||
_tcp = TCPServer.new()
|
||||
var err := _tcp.listen(port, interface)
|
||||
if err != OK:
|
||||
_tcp = null
|
||||
return false
|
||||
set_process(true)
|
||||
return true
|
||||
|
||||
|
||||
func stop() -> void:
|
||||
set_process(false)
|
||||
for c in _clients:
|
||||
(c.peer as StreamPeerTCP).disconnect_from_host()
|
||||
_clients.clear()
|
||||
if _tcp:
|
||||
_tcp.stop()
|
||||
_tcp = null
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if _tcp == null:
|
||||
return
|
||||
|
||||
while _tcp.is_connection_available():
|
||||
var peer := _tcp.take_connection()
|
||||
_clients.append({"peer": peer, "buf": PackedByteArray()})
|
||||
|
||||
var still_alive: Array = []
|
||||
for c in _clients:
|
||||
var peer: StreamPeerTCP = c.peer
|
||||
peer.poll()
|
||||
if peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||
continue
|
||||
var available := peer.get_available_bytes()
|
||||
if available > 0:
|
||||
var chunk := peer.get_data(available)
|
||||
if chunk[0] == OK:
|
||||
c.buf.append_array(chunk[1])
|
||||
_drain_messages(c)
|
||||
if (c.buf as PackedByteArray).size() > MAX_MESSAGE_BYTES:
|
||||
_send_error(peer, null, -32700, "message exceeds %d bytes" % MAX_MESSAGE_BYTES)
|
||||
peer.disconnect_from_host()
|
||||
continue
|
||||
still_alive.append(c)
|
||||
_clients = still_alive
|
||||
|
||||
|
||||
func _drain_messages(c: Dictionary) -> void:
|
||||
while true:
|
||||
var buf: PackedByteArray = c.buf
|
||||
var nl := buf.find(0x0A) # \n
|
||||
if nl < 0:
|
||||
return
|
||||
var line_bytes := buf.slice(0, nl)
|
||||
c.buf = buf.slice(nl + 1)
|
||||
var line := line_bytes.get_string_from_utf8().strip_edges()
|
||||
if line == "":
|
||||
continue
|
||||
_handle_line(c.peer, line)
|
||||
|
||||
|
||||
func _handle_line(peer: StreamPeerTCP, line: String) -> void:
|
||||
var parsed = JSON.parse_string(line)
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
_send_error(peer, null, -32700, "parse error — expected JSON object")
|
||||
return
|
||||
var id = parsed.get("id")
|
||||
var method: String = parsed.get("method", "")
|
||||
var params: Dictionary = parsed.get("params", {})
|
||||
if method == "":
|
||||
_send_error(peer, id, -32600, "missing 'method'")
|
||||
return
|
||||
|
||||
var result: Dictionary = _registry.dispatch(method, params)
|
||||
# Guard against silent tool-module failures: when a tool's .gd file has a parse
|
||||
# error, preload() returns null, calls on it produce no output, and dispatch
|
||||
# returns its default {}. Surface that instead of sending {"result": null}.
|
||||
if result.is_empty():
|
||||
_send_error(peer, id, -32000,
|
||||
"tool returned empty response — likely a parse error in the tool module. Check Godot's Output panel for the actual error.",
|
||||
{"method": method})
|
||||
elif result.has("error"):
|
||||
var e: Dictionary = result.error
|
||||
_send_error(peer, id, e.get("code", -32000), e.get("message", ""), e.get("data"))
|
||||
elif result.has("data"):
|
||||
_send_result(peer, id, result.data)
|
||||
else:
|
||||
_send_error(peer, id, -32000,
|
||||
"tool returned malformed response (missing both 'data' and 'error')",
|
||||
{"method": method})
|
||||
|
||||
|
||||
func _send_result(peer: StreamPeerTCP, id, data) -> void:
|
||||
_write(peer, {"id": id, "result": data})
|
||||
|
||||
|
||||
func _send_error(peer: StreamPeerTCP, id, code: int, msg: String, data = null) -> void:
|
||||
var err := {"code": code, "message": msg}
|
||||
if data != null:
|
||||
err["data"] = data
|
||||
_write(peer, {"id": id, "error": err})
|
||||
|
||||
|
||||
func _write(peer: StreamPeerTCP, obj: Dictionary) -> void:
|
||||
var line := JSON.stringify(obj) + "\n"
|
||||
peer.put_data(line.to_utf8_buffer())
|
||||
@@ -0,0 +1 @@
|
||||
uid://pcfsf0ijlm8x
|
||||
@@ -0,0 +1,306 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# Shared JSON-to-Godot type coercion for scene.set_property, resource.set_property,
|
||||
# and any other tool that assigns a user-provided value into a typed property slot.
|
||||
#
|
||||
# Contract:
|
||||
# coerce(value, target_type: int) -> Variant
|
||||
#
|
||||
# Returns the coerced value on success. On mismatch, returns a Dictionary with
|
||||
# a single "_error" key describing the failure — callers check `is Dictionary`
|
||||
# and forward the message as a tool-level error.
|
||||
#
|
||||
# New types go here. Don't re-add `_coerce` to individual tool files; route
|
||||
# through Coerce.coerce() instead.
|
||||
|
||||
static func coerce(value, target_type: int):
|
||||
match target_type:
|
||||
TYPE_BOOL:
|
||||
# Godot 4 has no `bool()` constructor (it errors at runtime). Convert via
|
||||
# truthiness, but parse strings explicitly so "false"/"0" don't read as
|
||||
# truthy the way any non-empty string otherwise would.
|
||||
if value is String:
|
||||
return value.strip_edges().to_lower() in ["true", "1", "yes", "on"]
|
||||
return true if value else false
|
||||
TYPE_INT:
|
||||
return int(value)
|
||||
TYPE_FLOAT:
|
||||
return float(value)
|
||||
TYPE_STRING, TYPE_STRING_NAME:
|
||||
return String(value)
|
||||
TYPE_NODE_PATH:
|
||||
return NodePath(String(value))
|
||||
TYPE_VECTOR2:
|
||||
if value is Array and value.size() == 2:
|
||||
return Vector2(value[0], value[1])
|
||||
return {"_error": "Vector2 expects [x, y]"}
|
||||
TYPE_VECTOR2I:
|
||||
if value is Array and value.size() == 2:
|
||||
return Vector2i(int(value[0]), int(value[1]))
|
||||
return {"_error": "Vector2i expects [x, y]"}
|
||||
TYPE_VECTOR3:
|
||||
if value is Array and value.size() == 3:
|
||||
return Vector3(value[0], value[1], value[2])
|
||||
return {"_error": "Vector3 expects [x, y, z]"}
|
||||
TYPE_VECTOR3I:
|
||||
if value is Array and value.size() == 3:
|
||||
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
||||
return {"_error": "Vector3i expects [x, y, z]"}
|
||||
TYPE_VECTOR4:
|
||||
if value is Array and value.size() == 4:
|
||||
return Vector4(value[0], value[1], value[2], value[3])
|
||||
return {"_error": "Vector4 expects [x, y, z, w]"}
|
||||
TYPE_VECTOR4I:
|
||||
if value is Array and value.size() == 4:
|
||||
return Vector4i(int(value[0]), int(value[1]), int(value[2]), int(value[3]))
|
||||
return {"_error": "Vector4i expects [x, y, z, w]"}
|
||||
TYPE_RECT2:
|
||||
if value is Array and value.size() == 4:
|
||||
return Rect2(value[0], value[1], value[2], value[3])
|
||||
return {"_error": "Rect2 expects [x, y, width, height]"}
|
||||
TYPE_RECT2I:
|
||||
if value is Array and value.size() == 4:
|
||||
return Rect2i(int(value[0]), int(value[1]), int(value[2]), int(value[3]))
|
||||
return {"_error": "Rect2i expects [x, y, width, height]"}
|
||||
TYPE_QUATERNION:
|
||||
if value is Array and value.size() == 4:
|
||||
return Quaternion(value[0], value[1], value[2], value[3])
|
||||
return {"_error": "Quaternion expects [x, y, z, w]"}
|
||||
TYPE_TRANSFORM2D:
|
||||
# TRS form: {origin: [x,y], rotation: radians, scale: [x,y]} — all optional.
|
||||
if value is Dictionary:
|
||||
var origin = value.get("origin", [0, 0])
|
||||
var rotation: float = float(value.get("rotation", 0.0))
|
||||
var scale_v = value.get("scale", [1, 1])
|
||||
var skew: float = float(value.get("skew", 0.0))
|
||||
if not (origin is Array and origin.size() == 2 and scale_v is Array and scale_v.size() == 2):
|
||||
return {"_error": "Transform2D origin/scale expect [x, y]"}
|
||||
return Transform2D(
|
||||
rotation,
|
||||
Vector2(scale_v[0], scale_v[1]),
|
||||
skew,
|
||||
Vector2(origin[0], origin[1])
|
||||
)
|
||||
return {"_error": "Transform2D expects {origin: [x,y], rotation: radians, scale: [x,y], skew?}"}
|
||||
TYPE_TRANSFORM3D:
|
||||
# TRS form: {origin: [x,y,z], rotation: [x,y,z] euler radians, scale: [x,y,z]}.
|
||||
if value is Dictionary:
|
||||
var origin = value.get("origin", [0, 0, 0])
|
||||
var rotation = value.get("rotation", [0, 0, 0])
|
||||
var scale_v = value.get("scale", [1, 1, 1])
|
||||
if not (origin is Array and origin.size() == 3
|
||||
and rotation is Array and rotation.size() == 3
|
||||
and scale_v is Array and scale_v.size() == 3):
|
||||
return {"_error": "Transform3D origin/rotation/scale expect [x, y, z]"}
|
||||
var basis := Basis.from_euler(Vector3(rotation[0], rotation[1], rotation[2]))
|
||||
basis = basis.scaled(Vector3(scale_v[0], scale_v[1], scale_v[2]))
|
||||
return Transform3D(basis, Vector3(origin[0], origin[1], origin[2]))
|
||||
return {"_error": "Transform3D expects {origin: [x,y,z], rotation: [x,y,z] (euler rad), scale: [x,y,z]}"}
|
||||
TYPE_BASIS:
|
||||
if value is Dictionary:
|
||||
var rotation = value.get("rotation", [0, 0, 0])
|
||||
var scale_v = value.get("scale", [1, 1, 1])
|
||||
if not (rotation is Array and rotation.size() == 3
|
||||
and scale_v is Array and scale_v.size() == 3):
|
||||
return {"_error": "Basis rotation/scale expect [x, y, z]"}
|
||||
return Basis.from_euler(Vector3(rotation[0], rotation[1], rotation[2])).scaled(
|
||||
Vector3(scale_v[0], scale_v[1], scale_v[2]))
|
||||
return {"_error": "Basis expects {rotation: [x,y,z] (euler rad), scale: [x,y,z]}"}
|
||||
TYPE_AABB:
|
||||
if value is Dictionary:
|
||||
var pos = value.get("position", [0, 0, 0])
|
||||
var size = value.get("size", [0, 0, 0])
|
||||
if not (pos is Array and pos.size() == 3 and size is Array and size.size() == 3):
|
||||
return {"_error": "AABB position/size expect [x, y, z]"}
|
||||
return AABB(Vector3(pos[0], pos[1], pos[2]), Vector3(size[0], size[1], size[2]))
|
||||
return {"_error": "AABB expects {position: [x,y,z], size: [x,y,z]}"}
|
||||
TYPE_PLANE:
|
||||
if value is Dictionary:
|
||||
var normal = value.get("normal", [0, 1, 0])
|
||||
var d: float = float(value.get("d", 0.0))
|
||||
if not (normal is Array and normal.size() == 3):
|
||||
return {"_error": "Plane normal expects [x, y, z]"}
|
||||
return Plane(Vector3(normal[0], normal[1], normal[2]), d)
|
||||
return {"_error": "Plane expects {normal: [x,y,z], d: float}"}
|
||||
TYPE_COLOR:
|
||||
if value is Array and (value.size() == 3 or value.size() == 4):
|
||||
var a := float(value[3]) if value.size() == 4 else 1.0
|
||||
return Color(value[0], value[1], value[2], a)
|
||||
if value is String:
|
||||
return Color(value)
|
||||
return {"_error": "Color expects [r,g,b(,a)] or '#rrggbb(aa)'"}
|
||||
TYPE_PACKED_STRING_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedStringArray()
|
||||
for s in value:
|
||||
out.append(String(s))
|
||||
return out
|
||||
return {"_error": "PackedStringArray expects an array of strings"}
|
||||
TYPE_PACKED_INT32_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedInt32Array()
|
||||
for i in value:
|
||||
out.append(int(i))
|
||||
return out
|
||||
return {"_error": "PackedInt32Array expects an array of numbers"}
|
||||
TYPE_PACKED_INT64_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedInt64Array()
|
||||
for i in value:
|
||||
out.append(int(i))
|
||||
return out
|
||||
return {"_error": "PackedInt64Array expects an array of numbers"}
|
||||
TYPE_PACKED_FLOAT32_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedFloat32Array()
|
||||
for f in value:
|
||||
out.append(float(f))
|
||||
return out
|
||||
return {"_error": "PackedFloat32Array expects an array of numbers"}
|
||||
TYPE_PACKED_FLOAT64_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedFloat64Array()
|
||||
for f in value:
|
||||
out.append(float(f))
|
||||
return out
|
||||
return {"_error": "PackedFloat64Array expects an array of numbers"}
|
||||
TYPE_PACKED_VECTOR2_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedVector2Array()
|
||||
for v in value:
|
||||
if v is Array and v.size() == 2:
|
||||
out.append(Vector2(v[0], v[1]))
|
||||
else:
|
||||
return {"_error": "PackedVector2Array expects [[x, y], ...]"}
|
||||
return out
|
||||
return {"_error": "PackedVector2Array expects an array of [x, y] pairs"}
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedVector3Array()
|
||||
for v in value:
|
||||
if v is Array and v.size() == 3:
|
||||
out.append(Vector3(v[0], v[1], v[2]))
|
||||
else:
|
||||
return {"_error": "PackedVector3Array expects [[x, y, z], ...]"}
|
||||
return out
|
||||
return {"_error": "PackedVector3Array expects an array of [x, y, z] triples"}
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
if value is Array:
|
||||
var out := PackedColorArray()
|
||||
for c in value:
|
||||
if c is Array and (c.size() == 3 or c.size() == 4):
|
||||
var a := float(c[3]) if c.size() == 4 else 1.0
|
||||
out.append(Color(c[0], c[1], c[2], a))
|
||||
elif c is String:
|
||||
out.append(Color(c))
|
||||
else:
|
||||
return {"_error": "PackedColorArray expects [[r,g,b(,a)], ...] or ['#hex', ...]"}
|
||||
return out
|
||||
return {"_error": "PackedColorArray expects an array of colors"}
|
||||
TYPE_OBJECT:
|
||||
# Auto-load a Resource when the caller passes a "res://..." or "uid://..." path string.
|
||||
# Without this, Godot silently drops the assignment and leaves the slot null.
|
||||
if value is String and (value.begins_with("res://") or value.begins_with("uid://")):
|
||||
var loaded := ResourceLoader.load(value)
|
||||
if loaded == null:
|
||||
return {"_error": "failed to load resource: %s" % value}
|
||||
return loaded
|
||||
return value
|
||||
_:
|
||||
return value
|
||||
|
||||
|
||||
# Inverse of coerce(): convert a Godot value back to a JSON-native form so the
|
||||
# response echo is unambiguous data rather than Godot's str() stringification
|
||||
# (which turns Vector2 into "(x, y)", Color into "(r, g, b, a)", null Object
|
||||
# into "<Object#null>", etc., all of which look like success strings).
|
||||
static func to_json(value):
|
||||
match typeof(value):
|
||||
TYPE_VECTOR2:
|
||||
return [value.x, value.y]
|
||||
TYPE_VECTOR2I:
|
||||
return [int(value.x), int(value.y)]
|
||||
TYPE_VECTOR3:
|
||||
return [value.x, value.y, value.z]
|
||||
TYPE_VECTOR3I:
|
||||
return [int(value.x), int(value.y), int(value.z)]
|
||||
TYPE_VECTOR4:
|
||||
return [value.x, value.y, value.z, value.w]
|
||||
TYPE_VECTOR4I:
|
||||
return [int(value.x), int(value.y), int(value.z), int(value.w)]
|
||||
TYPE_RECT2:
|
||||
return [value.position.x, value.position.y, value.size.x, value.size.y]
|
||||
TYPE_RECT2I:
|
||||
return [int(value.position.x), int(value.position.y), int(value.size.x), int(value.size.y)]
|
||||
TYPE_COLOR:
|
||||
return [value.r, value.g, value.b, value.a]
|
||||
TYPE_QUATERNION:
|
||||
return [value.x, value.y, value.z, value.w]
|
||||
TYPE_NODE_PATH:
|
||||
return String(value)
|
||||
TYPE_STRING_NAME:
|
||||
return String(value)
|
||||
TYPE_TRANSFORM2D:
|
||||
return {
|
||||
"origin": [value.origin.x, value.origin.y],
|
||||
"x": [value.x.x, value.x.y],
|
||||
"y": [value.y.x, value.y.y],
|
||||
}
|
||||
TYPE_TRANSFORM3D:
|
||||
return {
|
||||
"origin": [value.origin.x, value.origin.y, value.origin.z],
|
||||
"basis": to_json(value.basis),
|
||||
}
|
||||
TYPE_BASIS:
|
||||
return [
|
||||
[value.x.x, value.x.y, value.x.z],
|
||||
[value.y.x, value.y.y, value.y.z],
|
||||
[value.z.x, value.z.y, value.z.z],
|
||||
]
|
||||
TYPE_AABB:
|
||||
return {
|
||||
"position": [value.position.x, value.position.y, value.position.z],
|
||||
"size": [value.size.x, value.size.y, value.size.z],
|
||||
}
|
||||
TYPE_PLANE:
|
||||
return {
|
||||
"normal": [value.normal.x, value.normal.y, value.normal.z],
|
||||
"d": value.d,
|
||||
}
|
||||
TYPE_OBJECT:
|
||||
if value == null:
|
||||
return null
|
||||
if value is Resource and value.resource_path != "":
|
||||
return {"class": value.get_class(), "resource_path": value.resource_path}
|
||||
return {"class": (value as Object).get_class()}
|
||||
TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, \
|
||||
TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY:
|
||||
return Array(value)
|
||||
TYPE_PACKED_VECTOR2_ARRAY:
|
||||
var out_v2: Array = []
|
||||
for v in value:
|
||||
out_v2.append([v.x, v.y])
|
||||
return out_v2
|
||||
TYPE_PACKED_VECTOR3_ARRAY:
|
||||
var out_v3: Array = []
|
||||
for v in value:
|
||||
out_v3.append([v.x, v.y, v.z])
|
||||
return out_v3
|
||||
TYPE_PACKED_COLOR_ARRAY:
|
||||
var out_c: Array = []
|
||||
for c in value:
|
||||
out_c.append([c.r, c.g, c.b, c.a])
|
||||
return out_c
|
||||
TYPE_ARRAY:
|
||||
var out_a: Array = []
|
||||
for item in value:
|
||||
out_a.append(to_json(item))
|
||||
return out_a
|
||||
TYPE_DICTIONARY:
|
||||
var out_d: Dictionary = {}
|
||||
for k in value:
|
||||
out_d[str(k)] = to_json(value[k])
|
||||
return out_d
|
||||
_:
|
||||
return value
|
||||
@@ -0,0 +1 @@
|
||||
uid://cncjf8g7jufrc
|
||||
@@ -0,0 +1,210 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# Tools for working with AnimationPlayer nodes in the currently-edited scene.
|
||||
# Animations live in AnimationLibrary resources attached to the player; the "" library
|
||||
# is the default, named libraries prefix animations as "lib/anim".
|
||||
|
||||
# animation.list — list animations on an AnimationPlayer with their tracks.
|
||||
# Params: {node_path}
|
||||
static func list_animations(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path == "":
|
||||
return _err(-32602, "missing 'node_path'")
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
if not (node is AnimationPlayer):
|
||||
return _err(-32602, "node is not an AnimationPlayer: %s (%s)" % [node_path, node.get_class()])
|
||||
var ap: AnimationPlayer = node
|
||||
|
||||
var out: Array = []
|
||||
for lib_name in ap.get_animation_library_list():
|
||||
var lib: AnimationLibrary = ap.get_animation_library(lib_name)
|
||||
for anim_name in lib.get_animation_list():
|
||||
var anim: Animation = lib.get_animation(anim_name)
|
||||
var full_name: String
|
||||
if String(lib_name) == "":
|
||||
full_name = String(anim_name)
|
||||
else:
|
||||
full_name = "%s/%s" % [lib_name, anim_name]
|
||||
var tracks: Array = []
|
||||
for i in anim.get_track_count():
|
||||
tracks.append({
|
||||
"index": i,
|
||||
"type": _track_type_name(anim.track_get_type(i)),
|
||||
"path": String(anim.track_get_path(i)),
|
||||
"key_count": anim.track_get_key_count(i),
|
||||
})
|
||||
out.append({
|
||||
"name": full_name,
|
||||
"length": anim.length,
|
||||
"loop_mode": anim.loop_mode,
|
||||
"step": anim.step,
|
||||
"tracks": tracks,
|
||||
})
|
||||
return _ok({"node_path": node_path, "animations": out})
|
||||
|
||||
|
||||
# animation.add_animation — create an empty animation in the player's library.
|
||||
# Params: {node_path, name, length?: 1.0, library?: ""}
|
||||
static func add_animation(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("name", "")
|
||||
var length: float = float(params.get("length", 1.0))
|
||||
var library_name: String = params.get("library", "")
|
||||
if node_path == "" or anim_name == "":
|
||||
return _err(-32602, "missing 'node_path' or 'name'")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
if not (node is AnimationPlayer):
|
||||
return _err(-32602, "node is not an AnimationPlayer: %s (%s)" % [node_path, node.get_class()])
|
||||
var ap: AnimationPlayer = node
|
||||
|
||||
var lib: AnimationLibrary
|
||||
if ap.has_animation_library(library_name):
|
||||
lib = ap.get_animation_library(library_name)
|
||||
else:
|
||||
lib = AnimationLibrary.new()
|
||||
var add_err: int = ap.add_animation_library(library_name, lib)
|
||||
if add_err != OK:
|
||||
return _err(-32001, "add_animation_library failed: %d" % add_err)
|
||||
|
||||
if lib.has_animation(anim_name):
|
||||
return _err(-32602, "animation already exists: %s" % anim_name)
|
||||
|
||||
var anim := Animation.new()
|
||||
anim.length = length
|
||||
@warning_ignore("return_value_discarded")
|
||||
lib.add_animation(anim_name, anim)
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({"name": anim_name, "library": library_name, "length": length})
|
||||
|
||||
|
||||
# animation.remove_animation — delete an animation from the player.
|
||||
# Params: {node_path, name, library?: ""}
|
||||
static func remove_animation(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("name", "")
|
||||
var library_name: String = params.get("library", "")
|
||||
if node_path == "" or anim_name == "":
|
||||
return _err(-32602, "missing 'node_path' or 'name'")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
if not (node is AnimationPlayer):
|
||||
return _err(-32602, "node is not an AnimationPlayer")
|
||||
var ap: AnimationPlayer = node
|
||||
|
||||
if not ap.has_animation_library(library_name):
|
||||
return _err(-32001, "library not found: '%s'" % library_name)
|
||||
var lib: AnimationLibrary = ap.get_animation_library(library_name)
|
||||
if not lib.has_animation(anim_name):
|
||||
return _err(-32001, "animation not found: %s" % anim_name)
|
||||
lib.remove_animation(anim_name)
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({"removed": anim_name, "library": library_name})
|
||||
|
||||
|
||||
# animation.add_value_track — add a value track that animates a property over time.
|
||||
# target_node is resolved relative to the scene root (simpler than the
|
||||
# AnimationPlayer.root_node-relative resolution, which is fiddly).
|
||||
# Params: {node_path, animation, target_node, property, keyframes: [{time, value, easing?}]}
|
||||
static func add_value_track(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation", "")
|
||||
var target_node: String = params.get("target_node", "")
|
||||
var property_name: String = params.get("property", "")
|
||||
var keyframes: Array = params.get("keyframes", [])
|
||||
|
||||
if node_path == "" or anim_name == "" or target_node == "" or property_name == "":
|
||||
return _err(-32602, "missing 'node_path', 'animation', 'target_node', or 'property'")
|
||||
if keyframes.is_empty():
|
||||
return _err(-32602, "'keyframes' must contain at least one entry")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
var node: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if node == null:
|
||||
return _err(-32001, "node not found: %s" % node_path)
|
||||
if not (node is AnimationPlayer):
|
||||
return _err(-32602, "node is not an AnimationPlayer")
|
||||
var ap: AnimationPlayer = node
|
||||
|
||||
if not ap.has_animation(anim_name):
|
||||
return _err(-32001, "animation not found on this player: %s" % anim_name)
|
||||
var anim: Animation = ap.get_animation(anim_name)
|
||||
|
||||
# Validate that the target resolves from the scene root (a simpler contract
|
||||
# than AP.root_node-relative — callers think in scene-tree terms already).
|
||||
if root.get_node_or_null(target_node) == null:
|
||||
return _err(-32001, "target_node does not resolve from scene root: %s" % target_node)
|
||||
|
||||
var track_idx: int = anim.add_track(Animation.TYPE_VALUE)
|
||||
anim.track_set_path(track_idx, NodePath("%s:%s" % [target_node, property_name]))
|
||||
|
||||
var max_time: float = 0.0
|
||||
for kf in keyframes:
|
||||
if typeof(kf) != TYPE_DICTIONARY or not kf.has("time") or not kf.has("value"):
|
||||
return _err(-32602, "each keyframe needs {time, value}")
|
||||
var t: float = float(kf.time)
|
||||
var transition: float = float(kf.get("easing", 1.0))
|
||||
anim.track_insert_key(track_idx, t, kf.value, transition)
|
||||
if t > max_time:
|
||||
max_time = t
|
||||
|
||||
if max_time > anim.length:
|
||||
anim.length = max_time
|
||||
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
return _ok({
|
||||
"animation": anim_name,
|
||||
"track_index": track_idx,
|
||||
"target": "%s:%s" % [target_node, property_name],
|
||||
"keyframes_added": keyframes.size(),
|
||||
})
|
||||
|
||||
|
||||
static func _track_type_name(t: int) -> String:
|
||||
match t:
|
||||
Animation.TYPE_VALUE:
|
||||
return "value"
|
||||
Animation.TYPE_POSITION_3D:
|
||||
return "position_3d"
|
||||
Animation.TYPE_ROTATION_3D:
|
||||
return "rotation_3d"
|
||||
Animation.TYPE_SCALE_3D:
|
||||
return "scale_3d"
|
||||
Animation.TYPE_BLEND_SHAPE:
|
||||
return "blend_shape"
|
||||
Animation.TYPE_METHOD:
|
||||
return "method"
|
||||
Animation.TYPE_BEZIER:
|
||||
return "bezier"
|
||||
Animation.TYPE_AUDIO:
|
||||
return "audio"
|
||||
Animation.TYPE_ANIMATION:
|
||||
return "animation"
|
||||
_:
|
||||
return "unknown(%d)" % t
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://kurfdxg67ggk
|
||||
@@ -0,0 +1,296 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# client.* — manage MCP client config files so users don't have to hand-edit them.
|
||||
# Each client has its own config schema; we handle three shapes:
|
||||
# "mcp_servers" → {"mcpServers": {"<name>": {command, args}}}
|
||||
# Used by Claude Code, Claude Desktop, Cursor, Windsurf.
|
||||
# "vscode_servers" → {"servers": {"<name>": {command, args}}}
|
||||
# VS Code's native MCP config.
|
||||
# "continue_experimental" → {"experimental": {"modelContextProtocolServers":
|
||||
# [{"transport": {"type": "stdio", command, args}}]}}
|
||||
# Continue.dev's MCP config shape (array, not dict).
|
||||
#
|
||||
# Clients requiring JSONC (with comments) or non-file storage — Zed, VS Code
|
||||
# user-scope settings.json, Cline — aren't supported yet. Use manual config
|
||||
# for those; see the README per-client section.
|
||||
|
||||
const SERVER_NAME := "godot-agent-tools"
|
||||
const SERVER_COMMAND := "npx"
|
||||
const SERVER_ARGS := ["-y", "godot-agent-tools-mcp"]
|
||||
|
||||
|
||||
static func list_clients(_params: Dictionary) -> Dictionary:
|
||||
var out: Array = []
|
||||
for client_id in _client_ids():
|
||||
var info := _client_info(client_id)
|
||||
if info.has("_error"):
|
||||
out.append({"client": client_id, "error": info._error})
|
||||
continue
|
||||
var res := _read_config(info.path)
|
||||
var cfg: Dictionary = res.cfg
|
||||
var installed := _has_server_entry(cfg, info.format)
|
||||
out.append({
|
||||
"client": client_id,
|
||||
"config_path": info.path,
|
||||
"exists": _path_exists(info.path),
|
||||
"configured": installed,
|
||||
"unparseable": res.unparseable,
|
||||
})
|
||||
return _ok({"clients": out})
|
||||
|
||||
|
||||
static func configure(params: Dictionary) -> Dictionary:
|
||||
var client_id: String = params.get("client", "")
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
if client_id == "":
|
||||
return _err(-32602, "missing 'client' (supported: %s)" % ", ".join(_client_ids()))
|
||||
var info := _client_info(client_id)
|
||||
if info.has("_error"):
|
||||
return _err(-32602, info._error)
|
||||
|
||||
var res := _read_config(info.path)
|
||||
if res.unparseable:
|
||||
return _err(-32001, "existing config is not valid JSON — refusing to overwrite (fix or remove it first): %s" % info.path)
|
||||
var cfg: Dictionary = res.cfg
|
||||
if _has_server_entry(cfg, info.format) and not overwrite:
|
||||
return _ok({"client": client_id, "path": info.path, "status": "already_configured"})
|
||||
|
||||
_set_server_entry(cfg, info.format)
|
||||
var write_err := _write_config(info.path, cfg)
|
||||
if write_err != "":
|
||||
return _err(-32001, write_err)
|
||||
return _ok({"client": client_id, "path": info.path, "status": "configured"})
|
||||
|
||||
|
||||
static func remove(params: Dictionary) -> Dictionary:
|
||||
var client_id: String = params.get("client", "")
|
||||
if client_id == "":
|
||||
return _err(-32602, "missing 'client'")
|
||||
var info := _client_info(client_id)
|
||||
if info.has("_error"):
|
||||
return _err(-32602, info._error)
|
||||
|
||||
var res := _read_config(info.path)
|
||||
if res.unparseable:
|
||||
return _err(-32001, "existing config is not valid JSON — refusing to modify (fix or remove it first): %s" % info.path)
|
||||
var cfg: Dictionary = res.cfg
|
||||
if not _has_server_entry(cfg, info.format):
|
||||
return _ok({"client": client_id, "path": info.path, "status": "not_present"})
|
||||
|
||||
_clear_server_entry(cfg, info.format)
|
||||
var write_err := _write_config(info.path, cfg)
|
||||
if write_err != "":
|
||||
return _err(-32001, write_err)
|
||||
return _ok({"client": client_id, "path": info.path, "status": "removed"})
|
||||
|
||||
|
||||
static func _client_ids() -> Array:
|
||||
return [
|
||||
"claude_code_project",
|
||||
"claude_code_user",
|
||||
"claude_desktop",
|
||||
"cursor_project",
|
||||
"cursor_user",
|
||||
"windsurf_user",
|
||||
"continue_user",
|
||||
"vscode_project",
|
||||
]
|
||||
|
||||
|
||||
static func _client_info(client_id: String) -> Dictionary:
|
||||
var home := OS.get_environment("USERPROFILE")
|
||||
if home == "":
|
||||
home = OS.get_environment("HOME")
|
||||
if home == "":
|
||||
return {"_error": "cannot resolve home directory (neither USERPROFILE nor HOME set)"}
|
||||
|
||||
match client_id:
|
||||
"claude_code_project":
|
||||
return {"path": ProjectSettings.globalize_path("res://.mcp.json"), "format": "mcp_servers"}
|
||||
"claude_code_user":
|
||||
return {"path": home.path_join(".claude.json"), "format": "mcp_servers"}
|
||||
"cursor_project":
|
||||
return {"path": ProjectSettings.globalize_path("res://.cursor/mcp.json"), "format": "mcp_servers"}
|
||||
"cursor_user":
|
||||
return {"path": home.path_join(".cursor").path_join("mcp.json"), "format": "mcp_servers"}
|
||||
"windsurf_user":
|
||||
return {"path": home.path_join(".codeium").path_join("windsurf").path_join("mcp_config.json"), "format": "mcp_servers"}
|
||||
"continue_user":
|
||||
return {"path": home.path_join(".continue").path_join("config.json"), "format": "continue_experimental"}
|
||||
"vscode_project":
|
||||
return {"path": ProjectSettings.globalize_path("res://.vscode/mcp.json"), "format": "vscode_servers"}
|
||||
"claude_desktop":
|
||||
var os_name := OS.get_name()
|
||||
var path: String
|
||||
if os_name == "macOS":
|
||||
path = home.path_join("Library").path_join("Application Support").path_join("Claude").path_join("claude_desktop_config.json")
|
||||
elif os_name == "Windows":
|
||||
var appdata := OS.get_environment("APPDATA")
|
||||
if appdata == "":
|
||||
return {"_error": "APPDATA env var not set on Windows"}
|
||||
path = appdata.path_join("Claude").path_join("claude_desktop_config.json")
|
||||
else:
|
||||
path = home.path_join(".config").path_join("Claude").path_join("claude_desktop_config.json")
|
||||
return {"path": path, "format": "mcp_servers"}
|
||||
_:
|
||||
return {"_error": "unknown client: %s (supported: %s)" % [client_id, ", ".join(_client_ids())]}
|
||||
|
||||
|
||||
static func _path_exists(path: String) -> bool:
|
||||
return FileAccess.file_exists(path)
|
||||
|
||||
|
||||
# Returns {"cfg": Dictionary, "unparseable": bool}. unparseable=true means the
|
||||
# file exists with non-empty content that is NOT a valid JSON object — callers
|
||||
# that go on to WRITE must abort, since merging into {} would wipe the user's
|
||||
# data (e.g. a large ~/.claude.json with a transient syntax error, or a JSONC
|
||||
# file with comments that JSON.parse_string can't handle).
|
||||
static func _read_config(path: String) -> Dictionary:
|
||||
var content := ""
|
||||
if _path_exists(path):
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f:
|
||||
content = f.get_as_text()
|
||||
f.close()
|
||||
if content.strip_edges() == "":
|
||||
return {"cfg": {}, "unparseable": false}
|
||||
var parsed = JSON.parse_string(content)
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return {"cfg": {}, "unparseable": true}
|
||||
return {"cfg": parsed, "unparseable": false}
|
||||
|
||||
|
||||
static func _write_config(path: String, cfg: Dictionary) -> String:
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return "mkdir failed for %s (%d)" % [dir_path, derr]
|
||||
# Back up any existing non-empty file before overwriting, so a mistaken merge
|
||||
# or an interrupted write is recoverable.
|
||||
if FileAccess.file_exists(path):
|
||||
var prev := FileAccess.open(path, FileAccess.READ)
|
||||
if prev:
|
||||
var prev_content := prev.get_as_text()
|
||||
prev.close()
|
||||
if prev_content.strip_edges() != "":
|
||||
var bak := FileAccess.open(path + ".bak", FileAccess.WRITE)
|
||||
if bak:
|
||||
bak.store_string(prev_content)
|
||||
bak.close()
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return "could not open for write: %s (error %d)" % [path, FileAccess.get_open_error()]
|
||||
f.store_string(JSON.stringify(cfg, " "))
|
||||
f.close()
|
||||
return ""
|
||||
|
||||
|
||||
# --- Format-specific readers/writers -----------------------------------------
|
||||
|
||||
static func _has_server_entry(cfg: Dictionary, format: String) -> bool:
|
||||
match format:
|
||||
"mcp_servers":
|
||||
var servers = cfg.get("mcpServers")
|
||||
return typeof(servers) == TYPE_DICTIONARY and (servers as Dictionary).has(SERVER_NAME)
|
||||
"vscode_servers":
|
||||
var servers = cfg.get("servers")
|
||||
return typeof(servers) == TYPE_DICTIONARY and (servers as Dictionary).has(SERVER_NAME)
|
||||
"continue_experimental":
|
||||
var exp = cfg.get("experimental")
|
||||
if typeof(exp) != TYPE_DICTIONARY:
|
||||
return false
|
||||
var arr = exp.get("modelContextProtocolServers")
|
||||
if typeof(arr) != TYPE_ARRAY:
|
||||
return false
|
||||
for entry in arr:
|
||||
if typeof(entry) == TYPE_DICTIONARY and _entry_matches_continue(entry):
|
||||
return true
|
||||
return false
|
||||
_:
|
||||
return false
|
||||
|
||||
|
||||
static func _set_server_entry(cfg: Dictionary, format: String) -> void:
|
||||
match format:
|
||||
"mcp_servers":
|
||||
if typeof(cfg.get("mcpServers")) != TYPE_DICTIONARY:
|
||||
cfg["mcpServers"] = {}
|
||||
(cfg["mcpServers"] as Dictionary)[SERVER_NAME] = {
|
||||
"command": SERVER_COMMAND,
|
||||
"args": SERVER_ARGS.duplicate(),
|
||||
}
|
||||
"vscode_servers":
|
||||
if typeof(cfg.get("servers")) != TYPE_DICTIONARY:
|
||||
cfg["servers"] = {}
|
||||
(cfg["servers"] as Dictionary)[SERVER_NAME] = {
|
||||
"command": SERVER_COMMAND,
|
||||
"args": SERVER_ARGS.duplicate(),
|
||||
}
|
||||
"continue_experimental":
|
||||
if typeof(cfg.get("experimental")) != TYPE_DICTIONARY:
|
||||
cfg["experimental"] = {}
|
||||
var exp: Dictionary = cfg["experimental"]
|
||||
if typeof(exp.get("modelContextProtocolServers")) != TYPE_ARRAY:
|
||||
exp["modelContextProtocolServers"] = []
|
||||
var arr: Array = exp["modelContextProtocolServers"]
|
||||
# Remove any existing match so we don't duplicate.
|
||||
var kept: Array = []
|
||||
for entry in arr:
|
||||
if typeof(entry) != TYPE_DICTIONARY or not _entry_matches_continue(entry):
|
||||
kept.append(entry)
|
||||
kept.append({
|
||||
"transport": {
|
||||
"type": "stdio",
|
||||
"command": SERVER_COMMAND,
|
||||
"args": SERVER_ARGS.duplicate(),
|
||||
},
|
||||
})
|
||||
exp["modelContextProtocolServers"] = kept
|
||||
|
||||
|
||||
static func _clear_server_entry(cfg: Dictionary, format: String) -> void:
|
||||
match format:
|
||||
"mcp_servers":
|
||||
var servers = cfg.get("mcpServers")
|
||||
if typeof(servers) == TYPE_DICTIONARY:
|
||||
(servers as Dictionary).erase(SERVER_NAME)
|
||||
"vscode_servers":
|
||||
var servers = cfg.get("servers")
|
||||
if typeof(servers) == TYPE_DICTIONARY:
|
||||
(servers as Dictionary).erase(SERVER_NAME)
|
||||
"continue_experimental":
|
||||
var exp = cfg.get("experimental")
|
||||
if typeof(exp) != TYPE_DICTIONARY:
|
||||
return
|
||||
var arr = exp.get("modelContextProtocolServers")
|
||||
if typeof(arr) != TYPE_ARRAY:
|
||||
return
|
||||
var kept: Array = []
|
||||
for entry in arr:
|
||||
if typeof(entry) != TYPE_DICTIONARY or not _entry_matches_continue(entry):
|
||||
kept.append(entry)
|
||||
exp["modelContextProtocolServers"] = kept
|
||||
|
||||
|
||||
# Continue's config uses a flat array with no `name` field — identify our entry
|
||||
# by the transport command+args combo.
|
||||
static func _entry_matches_continue(entry: Dictionary) -> bool:
|
||||
var transport = entry.get("transport")
|
||||
if typeof(transport) != TYPE_DICTIONARY:
|
||||
return false
|
||||
if transport.get("command") != SERVER_COMMAND:
|
||||
return false
|
||||
var a = transport.get("args")
|
||||
if typeof(a) != TYPE_ARRAY or (a as Array).size() < 2:
|
||||
return false
|
||||
return a[1] == SERVER_ARGS[1]
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3u1el0113l81
|
||||
@@ -0,0 +1,72 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# docs.class_ref — return the public API of a Godot class so the agent can plan
|
||||
# work without guessing method/property/signal names.
|
||||
# Params: {class_name, include_inherited?: false}
|
||||
# Returns: {class, parent, methods: [{name, args, return}], properties, signals, constants}
|
||||
static func class_ref(params: Dictionary) -> Dictionary:
|
||||
var cname: String = params.get("class_name", "")
|
||||
var include_inherited: bool = params.get("include_inherited", false)
|
||||
if cname == "":
|
||||
return _err(-32602, "missing 'class_name'")
|
||||
if not ClassDB.class_exists(cname):
|
||||
return _err(-32602, "unknown class: %s" % cname)
|
||||
|
||||
var no_inheritance := not include_inherited
|
||||
|
||||
var methods: Array = []
|
||||
for m in ClassDB.class_get_method_list(cname, no_inheritance):
|
||||
# Skip internal / editor-only methods — they're noise for an agent.
|
||||
if (m.flags as int) & METHOD_FLAG_OBJECT_CORE:
|
||||
continue
|
||||
var args: Array = []
|
||||
for a in m.args:
|
||||
args.append({"name": a.name, "type": type_string(a.type)})
|
||||
methods.append({
|
||||
"name": m.name,
|
||||
"args": args,
|
||||
"return": type_string(m.return.type) if m.has("return") else "void",
|
||||
"virtual": bool((m.flags as int) & METHOD_FLAG_VIRTUAL),
|
||||
"static": bool((m.flags as int) & METHOD_FLAG_STATIC),
|
||||
})
|
||||
|
||||
var properties: Array = []
|
||||
for p in ClassDB.class_get_property_list(cname, no_inheritance):
|
||||
# Only user-facing properties (storage + editor). Internal group markers have usage=0.
|
||||
if not ((p.usage as int) & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
properties.append({
|
||||
"name": p.name,
|
||||
"type": type_string(p.type),
|
||||
"hint": p.get("hint_string", ""),
|
||||
})
|
||||
|
||||
var signals: Array = []
|
||||
for s in ClassDB.class_get_signal_list(cname, no_inheritance):
|
||||
var args: Array = []
|
||||
for a in s.args:
|
||||
args.append({"name": a.name, "type": type_string(a.type)})
|
||||
signals.append({"name": s.name, "args": args})
|
||||
|
||||
var constants: Array = []
|
||||
for c in ClassDB.class_get_integer_constant_list(cname, no_inheritance):
|
||||
constants.append({"name": c, "value": ClassDB.class_get_integer_constant(cname, c)})
|
||||
|
||||
return _ok({
|
||||
"class": cname,
|
||||
"parent": ClassDB.get_parent_class(cname),
|
||||
"inherited_included": include_inherited,
|
||||
"methods": methods,
|
||||
"properties": properties,
|
||||
"signals": signals,
|
||||
"constants": constants,
|
||||
})
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwarklg56vsim
|
||||
@@ -0,0 +1,201 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# editor.reload_filesystem — trigger an editor filesystem rescan.
|
||||
# Run this after external file changes (created/moved/deleted by tools outside the editor)
|
||||
# so load() and the FileSystem dock reflect reality.
|
||||
static func reload_filesystem(_params: Dictionary) -> Dictionary:
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
return {"data": {"scanned": true}}
|
||||
|
||||
|
||||
# editor.save_all_scenes — save every open edited scene.
|
||||
static func save_all_scenes(_params: Dictionary) -> Dictionary:
|
||||
EditorInterface.save_all_scenes()
|
||||
return {"data": {"saved": true}}
|
||||
|
||||
|
||||
# editor.state — consolidated editor + project status in one call.
|
||||
# Replaces piecewise scene.current + project.get_setting + etc.
|
||||
static func state(_params: Dictionary) -> Dictionary:
|
||||
var version := Engine.get_version_info()
|
||||
var version_str: String = "%d.%d.%d-%s" % [version.major, version.minor, version.patch, version.status]
|
||||
|
||||
var current_scene_path := ""
|
||||
var current_scene_class := ""
|
||||
var current_scene_name := ""
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root != null:
|
||||
current_scene_path = root.scene_file_path
|
||||
current_scene_class = root.get_class()
|
||||
current_scene_name = String(root.name)
|
||||
|
||||
var open_scenes: Array = []
|
||||
for s in EditorInterface.get_open_scenes():
|
||||
open_scenes.append(s)
|
||||
|
||||
var project_name := ""
|
||||
if ProjectSettings.has_setting("application/config/name"):
|
||||
project_name = str(ProjectSettings.get_setting("application/config/name"))
|
||||
|
||||
return {"data": {
|
||||
"godot_version": version_str,
|
||||
"project_name": project_name,
|
||||
"current_scene": {
|
||||
"path": current_scene_path,
|
||||
"root_name": current_scene_name,
|
||||
"root_class": current_scene_class,
|
||||
"open": root != null,
|
||||
},
|
||||
"open_scenes": open_scenes,
|
||||
"playing_scene": EditorInterface.is_playing_scene(),
|
||||
"playing_scene_path": EditorInterface.get_playing_scene() if EditorInterface.is_playing_scene() else "",
|
||||
}}
|
||||
|
||||
|
||||
# editor.selection_get — return the currently-selected nodes in the editor tree dock.
|
||||
# Useful for "operate on whatever I clicked" workflows.
|
||||
static func selection_get(_params: Dictionary) -> Dictionary:
|
||||
var selection := EditorInterface.get_selection()
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
var out: Array = []
|
||||
for node in selection.get_selected_nodes():
|
||||
var path_str := ""
|
||||
if root != null and (node == root or root.is_ancestor_of(node)):
|
||||
path_str = String(root.get_path_to(node))
|
||||
out.append({
|
||||
"name": String(node.name),
|
||||
"class": node.get_class(),
|
||||
"node_path": path_str,
|
||||
})
|
||||
return {"data": {"selected": out, "count": out.size()}}
|
||||
|
||||
|
||||
# editor.selection_set — select a specific set of nodes in the editor tree dock.
|
||||
# Params: {node_paths: ["Player", "Enemy"]}
|
||||
static func selection_set(params: Dictionary) -> Dictionary:
|
||||
var paths: Array = params.get("node_paths", [])
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return {"error": {"code": -32001, "message": "no scene open"}}
|
||||
|
||||
var selection := EditorInterface.get_selection()
|
||||
selection.clear()
|
||||
|
||||
var resolved: Array = []
|
||||
var missing: Array = []
|
||||
for p in paths:
|
||||
var node: Node = root if p == "." else root.get_node_or_null(p)
|
||||
if node == null:
|
||||
missing.append(p)
|
||||
continue
|
||||
selection.add_node(node)
|
||||
resolved.append(p)
|
||||
|
||||
if not missing.is_empty():
|
||||
return {"error": {"code": -32001, "message": "nodes not found: %s" % str(missing)}}
|
||||
return {"data": {"selected": resolved}}
|
||||
|
||||
|
||||
# editor.game_screenshot — capture the viewport of the currently-running game.
|
||||
# Works when the user has pressed F5 (or equivalent) and a game process is running
|
||||
# with our runtime bridge autoload registered. Writes a request file, polls for
|
||||
# the bridge to produce the PNG, returns the path.
|
||||
#
|
||||
# Params: {output?: "res://.godot/agent_tools/game.png", timeout_ms?: 5000}
|
||||
static func game_screenshot(params: Dictionary) -> Dictionary:
|
||||
var output_path: String = params.get("output", "res://.godot/agent_tools/game_screenshot.png")
|
||||
var timeout_ms: int = int(params.get("timeout_ms", 5000))
|
||||
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return {"error": {"code": -32001,
|
||||
"message": "no game running — press F5 (or equivalent) to start the game, then call again. Alternative for arbitrary scenes: run.scene_headless with 'screenshot'."}}
|
||||
|
||||
var bridge_dir := "res://.godot/agent_tools/bridge"
|
||||
if not DirAccess.dir_exists_absolute(bridge_dir):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(bridge_dir)
|
||||
if derr != OK:
|
||||
return {"error": {"code": -32001, "message": "mkdir failed: %d" % derr}}
|
||||
|
||||
# Wipe stale output so we can detect when the bridge has written fresh data.
|
||||
if FileAccess.file_exists(output_path):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(output_path)
|
||||
|
||||
var request_path := bridge_dir + "/request.json"
|
||||
var request_id := str(Time.get_ticks_msec())
|
||||
var req := {
|
||||
"id": request_id,
|
||||
"type": "screenshot",
|
||||
"output": output_path,
|
||||
}
|
||||
var f := FileAccess.open(request_path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return {"error": {"code": -32001, "message": "could not write bridge request"}}
|
||||
f.store_string(JSON.stringify(req))
|
||||
f.close()
|
||||
|
||||
# Poll for response — bridge writes response.json with matching id when done.
|
||||
var response_path := bridge_dir + "/response.json"
|
||||
var deadline_usec: int = Time.get_ticks_usec() + timeout_ms * 1000
|
||||
while Time.get_ticks_usec() < deadline_usec:
|
||||
if FileAccess.file_exists(response_path):
|
||||
var rf := FileAccess.open(response_path, FileAccess.READ)
|
||||
if rf:
|
||||
var body := rf.get_as_text()
|
||||
rf.close()
|
||||
var parsed = JSON.parse_string(body)
|
||||
if typeof(parsed) == TYPE_DICTIONARY and parsed.get("id", "") == request_id:
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(response_path)
|
||||
if parsed.has("error"):
|
||||
return {"error": {"code": -32001, "message": "bridge: %s" % parsed.error}}
|
||||
return {"data": {
|
||||
"path": output_path,
|
||||
"captured": FileAccess.file_exists(output_path),
|
||||
}}
|
||||
OS.delay_msec(50)
|
||||
|
||||
# Clean up stale request if bridge never picked it up.
|
||||
if FileAccess.file_exists(request_path):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return {"error": {"code": -32001,
|
||||
"message": "bridge timed out after %dms — is the running game using the _MCPGameBridge autoload? Re-enable the plugin to re-register it, then press F5 again." % timeout_ms}}
|
||||
|
||||
|
||||
# logs.read — pull recent log lines captured by the game-bridge autoload.
|
||||
# Params: {clear?: false, max_lines?: 200}
|
||||
static func logs_read(params: Dictionary) -> Dictionary:
|
||||
var clear: bool = params.get("clear", false)
|
||||
var max_lines: int = int(params.get("max_lines", 200))
|
||||
var logs_path := "res://.godot/agent_tools/bridge/logs.json"
|
||||
if not FileAccess.file_exists(logs_path):
|
||||
return {"data": {
|
||||
"entries": [],
|
||||
"count": 0,
|
||||
"note": "no log buffer found — the game may not be running or the autoload isn't initialized. Press F5 and try again.",
|
||||
}}
|
||||
var f := FileAccess.open(logs_path, FileAccess.READ)
|
||||
if f == null:
|
||||
return {"error": {"code": -32001, "message": "could not read logs"}}
|
||||
var parsed = JSON.parse_string(f.get_as_text())
|
||||
f.close()
|
||||
if typeof(parsed) != TYPE_ARRAY:
|
||||
parsed = []
|
||||
var entries: Array = parsed
|
||||
if entries.size() > max_lines:
|
||||
entries = entries.slice(entries.size() - max_lines)
|
||||
if clear:
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(logs_path)
|
||||
return {"data": {"entries": entries, "count": entries.size()}}
|
||||
|
||||
|
||||
# logs.clear — drop the log buffer regardless of read state.
|
||||
static func logs_clear(_params: Dictionary) -> Dictionary:
|
||||
var logs_path := "res://.godot/agent_tools/bridge/logs.json"
|
||||
if FileAccess.file_exists(logs_path):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.remove_absolute(logs_path)
|
||||
return {"data": {"cleared": true}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgoufu3l40rvx
|
||||
@@ -0,0 +1,144 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# fs.list — enumerate project files by type, with optional glob filter.
|
||||
# Params: {type?: "all"|"scene"|"script"|"resource"|"shader"|"image"|"audio",
|
||||
# glob?: "res://scenes/**/*.tscn",
|
||||
# include_addons?: false}
|
||||
# Returns: {type, count, files: [paths]}
|
||||
static func list(params: Dictionary) -> Dictionary:
|
||||
var type_filter: String = params.get("type", "all")
|
||||
var glob: String = params.get("glob", "")
|
||||
var include_addons: bool = params.get("include_addons", false)
|
||||
|
||||
var exts: Array
|
||||
match type_filter:
|
||||
"", "all":
|
||||
exts = ["gd", "cs", "tscn", "tres", "res", "gdshader", "gdshaderinc",
|
||||
"png", "jpg", "jpeg", "svg", "webp",
|
||||
"ogg", "wav", "mp3",
|
||||
"json", "cfg"]
|
||||
"scene":
|
||||
exts = ["tscn"]
|
||||
"script":
|
||||
exts = ["gd", "cs"]
|
||||
"resource":
|
||||
exts = ["tres", "res"]
|
||||
"shader":
|
||||
exts = ["gdshader", "gdshaderinc"]
|
||||
"image":
|
||||
exts = ["png", "jpg", "jpeg", "svg", "webp"]
|
||||
"audio":
|
||||
exts = ["ogg", "wav", "mp3"]
|
||||
_:
|
||||
return _err(-32602, "unknown type: %s (use all|scene|script|resource|shader|image|audio)" % type_filter)
|
||||
|
||||
var files: Array = []
|
||||
_walk("res://", exts, files, include_addons)
|
||||
|
||||
if glob != "":
|
||||
var filtered: Array = []
|
||||
for f in files:
|
||||
if f.matchn(glob): # case-insensitive glob match
|
||||
filtered.append(f)
|
||||
files = filtered
|
||||
|
||||
files.sort()
|
||||
return _ok({
|
||||
"type": type_filter,
|
||||
"count": files.size(),
|
||||
"files": files,
|
||||
})
|
||||
|
||||
|
||||
# fs.read_text — read a text file under res://. Complement to user_fs.read.
|
||||
# Params: {path}
|
||||
static func read_text(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
if path == "":
|
||||
return _err(-32602, "missing 'path'")
|
||||
if not path.begins_with("res://"):
|
||||
return _err(-32602, "path must begin with 'res://' (use user_fs.read for user:// files)")
|
||||
if not _within_sandbox(path, "res://"):
|
||||
return _err(-32602, "path escapes the res:// project sandbox ('..' traversal not allowed): %s" % path)
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return _err(-32001, "failed to open %s (error %d)" % [path, FileAccess.get_open_error()])
|
||||
var size_bytes := f.get_length()
|
||||
var content := f.get_as_text()
|
||||
f.close()
|
||||
return _ok({"path": path, "content": content, "size_bytes": size_bytes})
|
||||
|
||||
|
||||
# fs.write_text — write a text file under res://. Creates parent directories
|
||||
# if needed. Triggers the editor's filesystem rescan so the new file shows up
|
||||
# in the FileSystem dock.
|
||||
# Params: {path, content, overwrite?: false}
|
||||
static func write_text(params: Dictionary) -> Dictionary:
|
||||
var path: String = params.get("path", "")
|
||||
var content: String = str(params.get("content", ""))
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
if path == "":
|
||||
return _err(-32602, "missing 'path'")
|
||||
if not path.begins_with("res://"):
|
||||
return _err(-32602, "path must begin with 'res://'")
|
||||
if not _within_sandbox(path, "res://"):
|
||||
return _err(-32602, "path escapes the res:// project sandbox ('..' traversal not allowed): %s" % path)
|
||||
if FileAccess.file_exists(path) and not overwrite:
|
||||
return _err(-32602, "file exists (pass overwrite:true to replace): %s" % path)
|
||||
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
|
||||
|
||||
var f := FileAccess.open(path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
return _err(-32001, "could not open for write: %s" % path)
|
||||
f.store_string(content)
|
||||
f.close()
|
||||
EditorInterface.get_resource_filesystem().update_file(path)
|
||||
return _ok({"path": path, "bytes_written": content.length()})
|
||||
|
||||
|
||||
static func _walk(dir_path: String, exts: Array, out: Array, include_addons: bool) -> void:
|
||||
var d := DirAccess.open(dir_path)
|
||||
if d == null:
|
||||
return
|
||||
d.list_dir_begin()
|
||||
while true:
|
||||
var name := d.get_next()
|
||||
if name == "":
|
||||
break
|
||||
if name.begins_with("."):
|
||||
continue
|
||||
var full := dir_path.path_join(name)
|
||||
if d.current_is_dir():
|
||||
if not include_addons and full == "res://addons/agent_tools":
|
||||
continue # always skip our own addon
|
||||
_walk(full, exts, out, include_addons)
|
||||
else:
|
||||
for e in exts:
|
||||
if name.ends_with("." + e):
|
||||
out.append(full)
|
||||
break
|
||||
d.list_dir_end()
|
||||
|
||||
|
||||
# Path-traversal guard. `begins_with("res://")` is NOT a sandbox — Godot resolves
|
||||
# `..` segments against the real filesystem, so "res://../../etc/x" escapes the
|
||||
# project. Globalize to an absolute path, collapse `..`, and confirm the result
|
||||
# stays inside the (globalized, collapsed) prefix root.
|
||||
static func _within_sandbox(path: String, prefix: String) -> bool:
|
||||
var root := ProjectSettings.globalize_path(prefix).simplify_path().trim_suffix("/")
|
||||
var abs := ProjectSettings.globalize_path(path).simplify_path()
|
||||
return abs == root or abs.begins_with(root + "/")
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxq0mo7kx65wd
|
||||
@@ -0,0 +1,243 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# input_map.add_action — register a new input action in project.godot.
|
||||
# Params: {name, deadzone?: 0.5}
|
||||
static func add_action(params: Dictionary) -> Dictionary:
|
||||
var action_name: String = params.get("name", "")
|
||||
if action_name == "":
|
||||
return _err(-32602, "missing 'name'")
|
||||
var deadzone: float = float(params.get("deadzone", 0.5))
|
||||
var key := "input/" + action_name
|
||||
if ProjectSettings.has_setting(key):
|
||||
return _err(-32602, "action already exists: %s" % action_name)
|
||||
ProjectSettings.set_setting(key, {
|
||||
"deadzone": deadzone,
|
||||
"events": [],
|
||||
})
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
# Keep the runtime InputMap in sync with the on-disk project settings —
|
||||
# ProjectSettings.save() only writes the file; it doesn't register actions
|
||||
# with InputMap until editor restart.
|
||||
if not InputMap.has_action(action_name):
|
||||
InputMap.add_action(action_name, deadzone)
|
||||
return _ok({"added": action_name})
|
||||
|
||||
|
||||
# input_map.add_event — attach an input event to an existing action.
|
||||
# Supported event shapes (pick one via 'type'):
|
||||
# {type: "key", keycode: "A" | "Space" | "F1" ..., physical?: true}
|
||||
# {type: "mouse_button", button_index: 1 (left) | 2 (right) | 3 (middle)}
|
||||
# {type: "joy_button", button_index: 0..}
|
||||
# Params: {action, event: <shape above>}
|
||||
static func add_event(params: Dictionary) -> Dictionary:
|
||||
var action_name: String = params.get("action", "")
|
||||
var event_spec: Dictionary = params.get("event", {})
|
||||
if action_name == "":
|
||||
return _err(-32602, "missing 'action'")
|
||||
if event_spec.is_empty():
|
||||
return _err(-32602, "missing 'event'")
|
||||
var key := "input/" + action_name
|
||||
if not ProjectSettings.has_setting(key):
|
||||
return _err(-32001, "action not found: %s" % action_name)
|
||||
|
||||
var event := _build_event(event_spec)
|
||||
if event == null:
|
||||
return _err(-32602, "invalid event spec — see tool description for supported shapes")
|
||||
|
||||
var current: Dictionary = ProjectSettings.get_setting(key)
|
||||
var events_arr: Array = current.get("events", [])
|
||||
events_arr.append(event)
|
||||
current["events"] = events_arr
|
||||
ProjectSettings.set_setting(key, current)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
# Sync InputMap so Input.is_action_pressed() etc. see the event without restart.
|
||||
if InputMap.has_action(action_name):
|
||||
InputMap.action_add_event(action_name, event)
|
||||
return _ok({"action": action_name, "event": event_spec})
|
||||
|
||||
|
||||
# input_map.list — return registered input actions with their events.
|
||||
# Params: {include_builtins?: false}
|
||||
# Defaults to user-defined only (parses the [input] section of project.godot,
|
||||
# which contains only user overrides — Godot's ~90 ui_* builtins live in-memory).
|
||||
# When include_builtins, falls back to InputMap which has the full union.
|
||||
static func list_actions(params: Dictionary) -> Dictionary:
|
||||
var include_builtins: bool = params.get("include_builtins", false)
|
||||
var items: Array = []
|
||||
|
||||
if include_builtins:
|
||||
for a in InputMap.get_actions():
|
||||
var described: Array = []
|
||||
for e in InputMap.action_get_events(a):
|
||||
described.append(_describe_event(e))
|
||||
items.append({
|
||||
"name": String(a),
|
||||
"deadzone": InputMap.action_get_deadzone(a),
|
||||
"events": described,
|
||||
})
|
||||
else:
|
||||
# Read straight from project.godot so newly-added actions show up even
|
||||
# if they haven't been registered with the runtime InputMap yet.
|
||||
var cf := ConfigFile.new()
|
||||
var err := cf.load("res://project.godot")
|
||||
if err == OK and cf.has_section("input"):
|
||||
for action_name in cf.get_section_keys("input"):
|
||||
var value: Dictionary = cf.get_value("input", action_name)
|
||||
var described: Array = []
|
||||
for e in value.get("events", []):
|
||||
described.append(_describe_event(e))
|
||||
items.append({
|
||||
"name": action_name,
|
||||
"deadzone": value.get("deadzone", 0.5),
|
||||
"events": described,
|
||||
})
|
||||
|
||||
return _ok({"actions": items})
|
||||
|
||||
|
||||
# input_map.remove_event — remove an event from an action by index.
|
||||
# Call input_map.list first to see indices; events are listed in the order they
|
||||
# were added.
|
||||
# Params: {action, event_index}
|
||||
static func remove_event(params: Dictionary) -> Dictionary:
|
||||
var action_name: String = params.get("action", "")
|
||||
if action_name == "":
|
||||
return _err(-32602, "missing 'action'")
|
||||
if not params.has("event_index"):
|
||||
return _err(-32602, "missing 'event_index'")
|
||||
var idx: int = int(params.event_index)
|
||||
var key := "input/" + action_name
|
||||
if not ProjectSettings.has_setting(key):
|
||||
return _err(-32001, "action not found: %s" % action_name)
|
||||
var current: Dictionary = ProjectSettings.get_setting(key)
|
||||
var events_arr: Array = current.get("events", [])
|
||||
if idx < 0 or idx >= events_arr.size():
|
||||
return _err(-32602, "event_index %d out of range (0..%d)" % [idx, events_arr.size() - 1])
|
||||
var removed_event = events_arr[idx]
|
||||
events_arr.remove_at(idx)
|
||||
current["events"] = events_arr
|
||||
ProjectSettings.set_setting(key, current)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
if InputMap.has_action(action_name) and removed_event != null:
|
||||
InputMap.action_erase_event(action_name, removed_event)
|
||||
return _ok({"action": action_name, "removed_index": idx})
|
||||
|
||||
|
||||
# input_map.remove_action — delete a user-registered action.
|
||||
static func remove_action(params: Dictionary) -> Dictionary:
|
||||
var action_name: String = params.get("name", "")
|
||||
if action_name == "":
|
||||
return _err(-32602, "missing 'name'")
|
||||
var key := "input/" + action_name
|
||||
if not ProjectSettings.has_setting(key):
|
||||
return _err(-32001, "action not found: %s" % action_name)
|
||||
ProjectSettings.clear(key)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
if InputMap.has_action(action_name):
|
||||
InputMap.erase_action(action_name)
|
||||
return _ok({"removed": action_name})
|
||||
|
||||
|
||||
static func _build_event(spec: Dictionary) -> InputEvent:
|
||||
# device defaults to -1 (ALL_DEVICES) matching the editor's "All Devices" default.
|
||||
# For local multiplayer bind device=0, device=1, etc. to distinguish controllers.
|
||||
var device: int = int(spec.get("device", -1))
|
||||
match spec.get("type", ""):
|
||||
"key":
|
||||
var e := InputEventKey.new()
|
||||
e.device = device
|
||||
var kc_raw = spec.get("keycode", "")
|
||||
var keycode := 0
|
||||
if kc_raw is int:
|
||||
keycode = kc_raw
|
||||
elif kc_raw is String:
|
||||
keycode = OS.find_keycode_from_string(kc_raw)
|
||||
if keycode == 0:
|
||||
return null
|
||||
if spec.get("physical", true):
|
||||
e.physical_keycode = keycode
|
||||
else:
|
||||
e.keycode = keycode
|
||||
return e
|
||||
"mouse_button":
|
||||
var e := InputEventMouseButton.new()
|
||||
e.device = device
|
||||
e.button_index = int(spec.get("button_index", 1))
|
||||
return e
|
||||
"joy_button":
|
||||
var e := InputEventJoypadButton.new()
|
||||
e.device = device
|
||||
e.button_index = int(spec.get("button_index", 0))
|
||||
return e
|
||||
"joy_motion":
|
||||
# axis accepts int 0..5 or one of:
|
||||
# "left_x" / "left_y" / "right_x" / "right_y" / "trigger_left" / "trigger_right"
|
||||
# axis_value: -1.0 (full negative) to 1.0 (full positive); sign picks the direction
|
||||
# that triggers the action.
|
||||
var e := InputEventJoypadMotion.new()
|
||||
e.device = device
|
||||
e.axis = _coerce_axis(spec.get("axis", 0))
|
||||
e.axis_value = float(spec.get("axis_value", 1.0))
|
||||
return e
|
||||
_:
|
||||
return null
|
||||
|
||||
|
||||
static func _coerce_axis(v) -> int:
|
||||
if v is int:
|
||||
return v
|
||||
if v is String:
|
||||
match v:
|
||||
"left_x":
|
||||
return JOY_AXIS_LEFT_X
|
||||
"left_y":
|
||||
return JOY_AXIS_LEFT_Y
|
||||
"right_x":
|
||||
return JOY_AXIS_RIGHT_X
|
||||
"right_y":
|
||||
return JOY_AXIS_RIGHT_Y
|
||||
"trigger_left":
|
||||
return JOY_AXIS_TRIGGER_LEFT
|
||||
"trigger_right":
|
||||
return JOY_AXIS_TRIGGER_RIGHT
|
||||
return 0
|
||||
|
||||
|
||||
static func _describe_event(e) -> Dictionary:
|
||||
if e is InputEventKey:
|
||||
var kc: int = e.physical_keycode if e.physical_keycode != 0 else e.keycode
|
||||
return {
|
||||
"type": "key",
|
||||
"keycode": OS.get_keycode_string(kc),
|
||||
"physical": e.physical_keycode != 0,
|
||||
"device": e.device,
|
||||
}
|
||||
if e is InputEventMouseButton:
|
||||
return {"type": "mouse_button", "button_index": e.button_index, "device": e.device}
|
||||
if e is InputEventJoypadButton:
|
||||
return {"type": "joy_button", "button_index": e.button_index, "device": e.device}
|
||||
if e is InputEventJoypadMotion:
|
||||
return {
|
||||
"type": "joy_motion",
|
||||
"axis": e.axis,
|
||||
"axis_value": e.axis_value,
|
||||
"device": e.device,
|
||||
}
|
||||
return {"type": "unknown", "class": (e as Object).get_class() if e else "null"}
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dp1m17i4hu7t1
|
||||
@@ -0,0 +1,56 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# performance.monitors — read Godot's built-in performance monitors.
|
||||
# Params: {monitors?: [string]} — specific monitor names, default = common set
|
||||
# Returns: {fps, frame_time_ms, process_time_ms, physics_time_ms, mem_static_mb,
|
||||
# mem_dynamic_mb, objects, resources, nodes, draw_calls, ...}
|
||||
static func monitors(params: Dictionary) -> Dictionary:
|
||||
var requested: Array = params.get("monitors", [])
|
||||
var out: Dictionary = {}
|
||||
|
||||
# Default set — the monitors agents actually want for quick perf snapshots.
|
||||
if requested.is_empty():
|
||||
out["fps"] = Performance.get_monitor(Performance.TIME_FPS)
|
||||
out["frame_time_ms"] = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
|
||||
out["physics_time_ms"] = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
|
||||
out["mem_static_mb"] = Performance.get_monitor(Performance.MEMORY_STATIC) / 1048576.0
|
||||
out["mem_static_max_mb"] = Performance.get_monitor(Performance.MEMORY_STATIC_MAX) / 1048576.0
|
||||
out["object_count"] = int(Performance.get_monitor(Performance.OBJECT_COUNT))
|
||||
out["resource_count"] = int(Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT))
|
||||
out["node_count"] = int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
|
||||
out["orphan_node_count"] = int(Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT))
|
||||
out["draw_calls"] = int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME))
|
||||
else:
|
||||
for name in requested:
|
||||
var enum_value := _resolve_monitor_name(String(name))
|
||||
if enum_value == -1:
|
||||
out[name] = null
|
||||
else:
|
||||
out[name] = Performance.get_monitor(enum_value)
|
||||
|
||||
return _ok({"monitors": out})
|
||||
|
||||
|
||||
# Map friendly strings to Performance.* enum values so callers don't have to
|
||||
# remember the integer IDs.
|
||||
static func _resolve_monitor_name(name: String) -> int:
|
||||
match name:
|
||||
"fps": return Performance.TIME_FPS
|
||||
"frame_time": return Performance.TIME_PROCESS
|
||||
"physics_time": return Performance.TIME_PHYSICS_PROCESS
|
||||
"mem_static": return Performance.MEMORY_STATIC
|
||||
"mem_static_max": return Performance.MEMORY_STATIC_MAX
|
||||
"objects": return Performance.OBJECT_COUNT
|
||||
"resources": return Performance.OBJECT_RESOURCE_COUNT
|
||||
"nodes": return Performance.OBJECT_NODE_COUNT
|
||||
"orphan_nodes": return Performance.OBJECT_ORPHAN_NODE_COUNT
|
||||
"draw_calls": return Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME
|
||||
"audio_latency": return Performance.AUDIO_OUTPUT_LATENCY
|
||||
"physics_2d_active_objects": return Performance.PHYSICS_2D_ACTIVE_OBJECTS
|
||||
"physics_3d_active_objects": return Performance.PHYSICS_3D_ACTIVE_OBJECTS
|
||||
_: return -1
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bijue5qgitvnk
|
||||
@@ -0,0 +1,139 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# physics.autofit_collision_shape_2d — compute a CollisionShape2D sized to a
|
||||
# sibling/child Sprite2D or AnimatedSprite2D's visual bounds. Common pattern
|
||||
# that's tedious to set up by hand: you have a sprite, you want a rectangle or
|
||||
# circle collider that roughly matches it.
|
||||
#
|
||||
# Params: {
|
||||
# node_path: CollisionShape2D to fit (created if missing when create:true)
|
||||
# source: NodePath to a Sprite2D / AnimatedSprite2D (auto-detected
|
||||
# among siblings if omitted)
|
||||
# shape: "rectangle" | "circle" | "capsule" (default "rectangle")
|
||||
# margin: float pixels to shrink by (default 0)
|
||||
# create: bool — if true and node_path doesn't exist, create it as
|
||||
# a child of the source's parent. Default false.
|
||||
# }
|
||||
static func autofit_collision_shape_2d(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var source_path: String = params.get("source", "")
|
||||
var shape_type: String = params.get("shape", "rectangle")
|
||||
var margin: float = float(params.get("margin", 0.0))
|
||||
var create: bool = params.get("create", false)
|
||||
|
||||
if node_path == "":
|
||||
return _err(-32602, "missing 'node_path'")
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if root == null:
|
||||
return _err(-32001, "no scene open")
|
||||
|
||||
var target: Node = root if node_path == "." else root.get_node_or_null(node_path)
|
||||
if target == null and not create:
|
||||
return _err(-32001, "node not found: %s (pass create:true to auto-create)" % node_path)
|
||||
|
||||
# Resolve the visual source.
|
||||
var source: Node = null
|
||||
if source_path != "":
|
||||
source = root.get_node_or_null(source_path)
|
||||
if source == null:
|
||||
return _err(-32001, "source not found: %s" % source_path)
|
||||
elif target != null:
|
||||
# Look among siblings for a Sprite2D / AnimatedSprite2D.
|
||||
var parent := target.get_parent()
|
||||
if parent:
|
||||
for sib in parent.get_children():
|
||||
if sib is Sprite2D or sib is AnimatedSprite2D:
|
||||
source = sib
|
||||
break
|
||||
if source == null:
|
||||
return _err(-32001, "no Sprite2D/AnimatedSprite2D source found — pass 'source'")
|
||||
|
||||
# Compute visual bounds (untransformed; CollisionShape2D inherits parent's transform).
|
||||
var bounds := _sprite_bounds(source)
|
||||
if bounds.size.x <= 0 or bounds.size.y <= 0:
|
||||
return _err(-32001, "source has no visible extents (%s)" % source.get_class())
|
||||
|
||||
# Create the CollisionShape2D if needed.
|
||||
if target == null and create:
|
||||
var inst := CollisionShape2D.new()
|
||||
inst.name = node_path.get_file() if "/" in node_path else node_path
|
||||
var parent := source.get_parent()
|
||||
parent.add_child(inst)
|
||||
inst.owner = root
|
||||
target = inst
|
||||
|
||||
if not target is CollisionShape2D:
|
||||
return _err(-32602, "target is not a CollisionShape2D: %s (%s)" % [node_path, target.get_class()])
|
||||
var cs: CollisionShape2D = target
|
||||
|
||||
# Build the requested shape sized to bounds.
|
||||
var shape_res: Shape2D
|
||||
match shape_type:
|
||||
"rectangle":
|
||||
var r := RectangleShape2D.new()
|
||||
r.size = Vector2(
|
||||
max(1.0, bounds.size.x - margin * 2.0),
|
||||
max(1.0, bounds.size.y - margin * 2.0)
|
||||
)
|
||||
shape_res = r
|
||||
"circle":
|
||||
var c := CircleShape2D.new()
|
||||
c.radius = max(1.0, min(bounds.size.x, bounds.size.y) / 2.0 - margin)
|
||||
shape_res = c
|
||||
"capsule":
|
||||
var cap := CapsuleShape2D.new()
|
||||
cap.radius = max(1.0, min(bounds.size.x, bounds.size.y) / 2.0 - margin)
|
||||
cap.height = max(cap.radius * 2.0 + 1.0, bounds.size.y - margin * 2.0)
|
||||
shape_res = cap
|
||||
_:
|
||||
return _err(-32602, "unknown shape '%s' (supported: rectangle, circle, capsule)" % shape_type)
|
||||
|
||||
cs.shape = shape_res
|
||||
# Center the CollisionShape2D on the sprite's center.
|
||||
cs.position = bounds.position + bounds.size / 2.0
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
|
||||
return _ok({
|
||||
"node_path": String(root.get_path_to(cs)),
|
||||
"shape": shape_type,
|
||||
"size": [bounds.size.x - margin * 2.0, bounds.size.y - margin * 2.0],
|
||||
"center": [cs.position.x, cs.position.y],
|
||||
"source": String(root.get_path_to(source)),
|
||||
})
|
||||
|
||||
|
||||
static func _sprite_bounds(source: Node) -> Rect2:
|
||||
if source is Sprite2D:
|
||||
var s: Sprite2D = source
|
||||
if s.texture == null:
|
||||
return Rect2(Vector2.ZERO, Vector2.ZERO)
|
||||
var tex_size: Vector2 = s.texture.get_size()
|
||||
if s.region_enabled:
|
||||
tex_size = s.region_rect.size
|
||||
var size: Vector2 = tex_size * Vector2(abs(s.scale.x), abs(s.scale.y))
|
||||
# For centered sprites, bounds are at (-size/2, -size/2).
|
||||
var offset: Vector2 = -size / 2.0 if s.centered else Vector2.ZERO
|
||||
offset += s.offset
|
||||
return Rect2(offset, size)
|
||||
if source is AnimatedSprite2D:
|
||||
var a: AnimatedSprite2D = source
|
||||
if a.sprite_frames == null or a.animation == "":
|
||||
return Rect2(Vector2.ZERO, Vector2.ZERO)
|
||||
var tex := a.sprite_frames.get_frame_texture(a.animation, a.frame)
|
||||
if tex == null:
|
||||
return Rect2(Vector2.ZERO, Vector2.ZERO)
|
||||
var size: Vector2 = tex.get_size() * Vector2(abs(a.scale.x), abs(a.scale.y))
|
||||
var offset: Vector2 = -size / 2.0 if a.centered else Vector2.ZERO
|
||||
offset += a.offset
|
||||
return Rect2(offset, size)
|
||||
return Rect2(Vector2.ZERO, Vector2.ZERO)
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c26p52t0pcj6q
|
||||
@@ -0,0 +1,89 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# project.get_setting — read a value from project.godot. Returns {exists: false} if unset.
|
||||
# Params: {key: "application/config/name"}
|
||||
static func get_setting(params: Dictionary) -> Dictionary:
|
||||
var key: String = params.get("key", "")
|
||||
if key == "":
|
||||
return _err(-32602, "missing 'key'")
|
||||
if not ProjectSettings.has_setting(key):
|
||||
return _ok({"key": key, "exists": false})
|
||||
return _ok({"key": key, "exists": true, "value": ProjectSettings.get_setting(key)})
|
||||
|
||||
|
||||
# project.set_setting — write a value to project.godot and save.
|
||||
# DESTRUCTIVE: mutates project.godot. Prefer targeted tools (autoload.add, etc.) when available.
|
||||
# Params: {key, value}
|
||||
static func set_setting(params: Dictionary) -> Dictionary:
|
||||
var key: String = params.get("key", "")
|
||||
if key == "":
|
||||
return _err(-32602, "missing 'key'")
|
||||
if not params.has("value"):
|
||||
return _err(-32602, "missing 'value'")
|
||||
ProjectSettings.set_setting(key, params["value"])
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "ProjectSettings.save() failed: %d" % err)
|
||||
return _ok({"key": key, "value": params["value"]})
|
||||
|
||||
|
||||
# autoload.add — register an autoload (singleton by default).
|
||||
# Params: {name, path, singleton?: true}
|
||||
static func autoload_add(params: Dictionary) -> Dictionary:
|
||||
var autoload_name: String = params.get("name", "")
|
||||
var path: String = params.get("path", "")
|
||||
var singleton: bool = params.get("singleton", true)
|
||||
if autoload_name == "" or path == "":
|
||||
return _err(-32602, "missing 'name' or 'path'")
|
||||
if not ResourceLoader.exists(path):
|
||||
return _err(-32001, "path does not exist: %s" % path)
|
||||
|
||||
var value := ("*" + path) if singleton else path
|
||||
ProjectSettings.set_setting("autoload/" + autoload_name, value)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
return _ok({"name": autoload_name, "path": path, "singleton": singleton})
|
||||
|
||||
|
||||
# autoload.remove — unregister an autoload.
|
||||
# Params: {name}
|
||||
static func autoload_remove(params: Dictionary) -> Dictionary:
|
||||
var autoload_name: String = params.get("name", "")
|
||||
if autoload_name == "":
|
||||
return _err(-32602, "missing 'name'")
|
||||
var key := "autoload/" + autoload_name
|
||||
if not ProjectSettings.has_setting(key):
|
||||
return _err(-32001, "autoload not found: %s" % autoload_name)
|
||||
ProjectSettings.clear(key)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return _err(-32001, "save failed: %d" % err)
|
||||
return _ok({"removed": autoload_name})
|
||||
|
||||
|
||||
# autoload.list — enumerate all registered autoloads.
|
||||
static func autoload_list(_params: Dictionary) -> Dictionary:
|
||||
var items: Array = []
|
||||
# ProjectSettings.get_property_list() contains our autoload entries under "autoload/*".
|
||||
for p in ProjectSettings.get_property_list():
|
||||
var name: String = p.name
|
||||
if not name.begins_with("autoload/"):
|
||||
continue
|
||||
var value: String = str(ProjectSettings.get_setting(name))
|
||||
var singleton := value.begins_with("*")
|
||||
items.append({
|
||||
"name": name.trim_prefix("autoload/"),
|
||||
"path": value.trim_prefix("*") if singleton else value,
|
||||
"singleton": singleton,
|
||||
})
|
||||
return _ok({"autoloads": items})
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bsfbwkk5nas36
|
||||
@@ -0,0 +1,371 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
|
||||
# refs.validate_project — project-wide sweep. No params.
|
||||
# Returns {checked: int, issues: [{path, kind, ...}]}.
|
||||
# Kinds: script_parse_error, scene_load_failed, scene_instantiate_failed,
|
||||
# resource_load_failed, missing_ext_resource, missing_uid,
|
||||
# signal_method_missing, signal_target_missing.
|
||||
static func validate_project(_params: Dictionary) -> Dictionary:
|
||||
var issues: Array = []
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd", "tscn", "tres"], files)
|
||||
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue # don't validate ourselves
|
||||
if path.ends_with(".gd"):
|
||||
_check_script(path, issues)
|
||||
elif path.ends_with(".tscn"):
|
||||
_check_scene(path, issues)
|
||||
elif path.ends_with(".tres"):
|
||||
_check_resource(path, issues)
|
||||
|
||||
return {"data": {"checked": files.size(), "issues": issues}}
|
||||
|
||||
|
||||
static func _walk(dir_path: String, exts: Array, out: Array) -> void:
|
||||
var d := DirAccess.open(dir_path)
|
||||
if d == null:
|
||||
return
|
||||
d.list_dir_begin()
|
||||
while true:
|
||||
var name := d.get_next()
|
||||
if name == "":
|
||||
break
|
||||
if name.begins_with("."):
|
||||
continue
|
||||
var full := dir_path.path_join(name)
|
||||
if d.current_is_dir():
|
||||
_walk(full, exts, out)
|
||||
else:
|
||||
for e in exts:
|
||||
if name.ends_with("." + e):
|
||||
out.append(full)
|
||||
break
|
||||
d.list_dir_end()
|
||||
|
||||
|
||||
static func _check_script(path: String, issues: Array) -> void:
|
||||
# Go through the normal loader so class_name / preload chains resolve against
|
||||
# the real global registry. A detached GDScript.new() + reload() would false-positive
|
||||
# on any script with class_name (duplicate global class registration).
|
||||
var script := ResourceLoader.load(path, "Script")
|
||||
if script == null:
|
||||
issues.append({"path": path, "kind": "script_load_failed"})
|
||||
|
||||
|
||||
static func _check_scene(path: String, issues: Array) -> void:
|
||||
_check_ext_resources_text(path, issues)
|
||||
|
||||
var packed := ResourceLoader.load(path, "PackedScene") as PackedScene
|
||||
if packed == null:
|
||||
issues.append({"path": path, "kind": "scene_load_failed"})
|
||||
return
|
||||
var root := packed.instantiate(PackedScene.GEN_EDIT_STATE_DISABLED)
|
||||
if root == null:
|
||||
issues.append({"path": path, "kind": "scene_instantiate_failed"})
|
||||
return
|
||||
_walk_scene_signals(root, root, path, issues)
|
||||
root.free()
|
||||
|
||||
|
||||
static func _walk_scene_signals(node: Node, scene_root: Node, scene_path: String, issues: Array) -> void:
|
||||
for sig in node.get_signal_list():
|
||||
var sig_name: String = sig.name
|
||||
for c in node.get_signal_connection_list(sig_name):
|
||||
var flags: int = c.flags
|
||||
if not (flags & CONNECT_PERSIST):
|
||||
continue # ignore runtime-only connections
|
||||
var cb: Callable = c.callable
|
||||
var target: Object = cb.get_object()
|
||||
var method: String = cb.get_method()
|
||||
if target == null:
|
||||
issues.append({
|
||||
"path": scene_path,
|
||||
"kind": "signal_target_missing",
|
||||
"node": String(scene_root.get_path_to(node)),
|
||||
"signal": sig_name,
|
||||
})
|
||||
elif not target.has_method(method):
|
||||
issues.append({
|
||||
"path": scene_path,
|
||||
"kind": "signal_method_missing",
|
||||
"node": String(scene_root.get_path_to(node)),
|
||||
"signal": sig_name,
|
||||
"method": method,
|
||||
})
|
||||
for child in node.get_children():
|
||||
if child.owner == scene_root or child == scene_root:
|
||||
_walk_scene_signals(child, scene_root, scene_path, issues)
|
||||
|
||||
|
||||
static func _check_resource(path: String, issues: Array) -> void:
|
||||
var res := ResourceLoader.load(path)
|
||||
if res == null:
|
||||
issues.append({"path": path, "kind": "resource_load_failed"})
|
||||
|
||||
|
||||
# refs.find_usages — find every file that references a given resource.
|
||||
# Target may be either a path ("res://player.gd") or a uid ("uid://abc123").
|
||||
# Searches for both forms so you catch uid-indirected references too.
|
||||
# Params: {target: "res://..." | "uid://..."}
|
||||
static func find_usages(params: Dictionary) -> Dictionary:
|
||||
var target: String = params.get("target", "")
|
||||
if target == "":
|
||||
return _err(-32602, "missing 'target'")
|
||||
|
||||
var terms: Array = [target]
|
||||
if target.begins_with("uid://"):
|
||||
var id := ResourceUID.text_to_id(target)
|
||||
if id != -1 and ResourceUID.has_id(id):
|
||||
var p := ResourceUID.get_id_path(id)
|
||||
if p != "" and p != target:
|
||||
terms.append(p)
|
||||
else:
|
||||
var id := ResourceLoader.get_resource_uid(target)
|
||||
if id != -1:
|
||||
terms.append(ResourceUID.id_to_text(id))
|
||||
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd", "tscn", "tres", "cfg", "gdshader", "gdshaderinc", "cs", "godot"], files)
|
||||
|
||||
var matches: Array = []
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue
|
||||
if path == target:
|
||||
continue # skip the file itself
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var line_num := 0
|
||||
while not f.eof_reached():
|
||||
line_num += 1
|
||||
var line := f.get_line()
|
||||
for term in terms:
|
||||
if term in line:
|
||||
matches.append({
|
||||
"path": path,
|
||||
"line": line_num,
|
||||
"matched": term,
|
||||
"text": line.strip_edges(),
|
||||
})
|
||||
break
|
||||
|
||||
return _ok({"target": target, "terms_searched": terms, "matches": matches})
|
||||
|
||||
|
||||
# refs.rename — move a file and rewrite all references to it.
|
||||
# Path-form references are text-replaced; uid-form references keep working because
|
||||
# the .uid sidecar moves with the file (preserving the UID → new path mapping).
|
||||
# Params: {from, to, overwrite?: false, dry_run?: false}
|
||||
static func rename(params: Dictionary) -> Dictionary:
|
||||
var from_path: String = params.get("from", "")
|
||||
var to_path: String = params.get("to", "")
|
||||
var overwrite: bool = params.get("overwrite", false)
|
||||
var dry_run: bool = params.get("dry_run", false)
|
||||
|
||||
if from_path == "" or to_path == "":
|
||||
return _err(-32602, "missing 'from' or 'to'")
|
||||
if not (from_path.begins_with("res://") and to_path.begins_with("res://")):
|
||||
return _err(-32602, "paths must be 'res://...'")
|
||||
if from_path == to_path:
|
||||
return _err(-32602, "'from' and 'to' are the same")
|
||||
if not FileAccess.file_exists(from_path):
|
||||
return _err(-32001, "source not found: %s" % from_path)
|
||||
if FileAccess.file_exists(to_path) and not overwrite:
|
||||
return _err(-32602, "destination exists (pass overwrite:true): %s" % to_path)
|
||||
|
||||
# Collect path-form usages. UID-form references are preserved automatically when
|
||||
# the .uid sidecar moves with the file, so we don't need to rewrite them.
|
||||
var usage := find_usages({"target": from_path})
|
||||
if usage.has("error"):
|
||||
return usage
|
||||
var edits: Dictionary = {}
|
||||
for m in usage.data.matches:
|
||||
if m.matched != from_path:
|
||||
continue # uid-form match — no rewrite needed
|
||||
if not edits.has(m.path):
|
||||
edits[m.path] = true
|
||||
|
||||
if dry_run:
|
||||
return _ok({
|
||||
"would_move": {"from": from_path, "to": to_path},
|
||||
"would_update": edits.keys(),
|
||||
})
|
||||
|
||||
var dir_path := to_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var derr := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if derr != OK:
|
||||
return _err(-32001, "mkdir failed (%d): %s" % [derr, dir_path])
|
||||
|
||||
var move_err := DirAccess.rename_absolute(from_path, to_path)
|
||||
if move_err != OK:
|
||||
return _err(-32001, "move failed (%d)" % move_err)
|
||||
|
||||
# Move .uid sidecar so UIDs continue resolving to the new path. Best-effort —
|
||||
# sidecar failures don't block the main rename that already succeeded.
|
||||
var from_uid := from_path + ".uid"
|
||||
if FileAccess.file_exists(from_uid):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.rename_absolute(from_uid, to_path + ".uid")
|
||||
# Move .import sidecar for imported assets (textures, audio, etc.).
|
||||
var from_import := from_path + ".import"
|
||||
if FileAccess.file_exists(from_import):
|
||||
@warning_ignore("return_value_discarded")
|
||||
DirAccess.rename_absolute(from_import, to_path + ".import")
|
||||
|
||||
var files_updated: Array = []
|
||||
for ref_path in edits.keys():
|
||||
var rf := FileAccess.open(ref_path, FileAccess.READ)
|
||||
if rf == null:
|
||||
continue
|
||||
var content := rf.get_as_text()
|
||||
rf.close()
|
||||
var new_content := content.replace(from_path, to_path)
|
||||
if new_content == content:
|
||||
continue
|
||||
var wf := FileAccess.open(ref_path, FileAccess.WRITE)
|
||||
if wf == null:
|
||||
continue
|
||||
wf.store_string(new_content)
|
||||
wf.close()
|
||||
files_updated.append(ref_path)
|
||||
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
return _ok({
|
||||
"moved": {"from": from_path, "to": to_path},
|
||||
"files_updated": files_updated,
|
||||
})
|
||||
|
||||
|
||||
# refs.rename_class — rename `class_name X` to `class_name Y` and rewrite every
|
||||
# word-boundary reference to X across .gd / .tscn / .tres files. Best-effort:
|
||||
# won't rewrite an X that happens to be a local variable name (word boundary
|
||||
# alone isn't semantic analysis). Use dry_run first on anything non-trivial.
|
||||
# Params: {from, to, dry_run?: false}
|
||||
static func rename_class(params: Dictionary) -> Dictionary:
|
||||
var from_name: String = params.get("from", "")
|
||||
var to_name: String = params.get("to", "")
|
||||
var dry_run: bool = params.get("dry_run", false)
|
||||
|
||||
if from_name == "" or to_name == "":
|
||||
return _err(-32602, "missing 'from' or 'to'")
|
||||
if not _is_valid_identifier(from_name):
|
||||
return _err(-32602, "'from' is not a valid identifier: %s" % from_name)
|
||||
if not _is_valid_identifier(to_name):
|
||||
return _err(-32602, "'to' is not a valid identifier: %s" % to_name)
|
||||
if from_name == to_name:
|
||||
return _err(-32602, "'from' and 'to' are identical")
|
||||
|
||||
var defining_script := _find_class_definition(from_name)
|
||||
if defining_script == "":
|
||||
return _err(-32001, "no script found declaring class_name %s" % from_name)
|
||||
if _find_class_definition(to_name) != "":
|
||||
return _err(-32602, "class_name %s already exists" % to_name)
|
||||
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd", "tscn", "tres"], files)
|
||||
|
||||
var regex := RegEx.new()
|
||||
regex.compile("\\b%s\\b" % from_name)
|
||||
|
||||
var edits: Array = []
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
if regex.search(text) != null:
|
||||
edits.append(path)
|
||||
|
||||
if dry_run:
|
||||
return _ok({
|
||||
"from": from_name,
|
||||
"to": to_name,
|
||||
"defining_script": defining_script,
|
||||
"would_update": edits,
|
||||
})
|
||||
|
||||
var updated: Array = []
|
||||
for path in edits:
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
var new_text := regex.sub(text, to_name, true)
|
||||
if new_text == text:
|
||||
continue
|
||||
var wf := FileAccess.open(path, FileAccess.WRITE)
|
||||
if wf == null:
|
||||
continue
|
||||
wf.store_string(new_text)
|
||||
wf.close()
|
||||
updated.append(path)
|
||||
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
return _ok({
|
||||
"from": from_name,
|
||||
"to": to_name,
|
||||
"defining_script": defining_script,
|
||||
"updated": updated,
|
||||
})
|
||||
|
||||
|
||||
static func _is_valid_identifier(s: String) -> bool:
|
||||
var re := RegEx.new()
|
||||
re.compile("^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
return re.search(s) != null
|
||||
|
||||
|
||||
static func _find_class_definition(class_name_val: String) -> String:
|
||||
var files: Array = []
|
||||
_walk("res://", ["gd"], files)
|
||||
var re := RegEx.new()
|
||||
re.compile("^\\s*class_name\\s+%s\\b" % class_name_val)
|
||||
for path in files:
|
||||
if path.begins_with("res://addons/agent_tools/"):
|
||||
continue
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
continue
|
||||
var text := f.get_as_text()
|
||||
f.close()
|
||||
for line in text.split("\n"):
|
||||
if re.search(line) != null:
|
||||
return path
|
||||
return ""
|
||||
|
||||
|
||||
static func _ok(data) -> Dictionary:
|
||||
return {"data": data}
|
||||
|
||||
|
||||
static func _err(code: int, msg: String) -> Dictionary:
|
||||
return {"error": {"code": code, "message": msg}}
|
||||
|
||||
|
||||
# Parse the raw .tscn/.tres text for [ext_resource ... path="..."] and verify each target resolves.
|
||||
# Catches references to files that were deleted or moved without updating the scene.
|
||||
static func _check_ext_resources_text(path: String, issues: Array) -> void:
|
||||
var f := FileAccess.open(path, FileAccess.READ)
|
||||
if f == null:
|
||||
return
|
||||
var text := f.get_as_text()
|
||||
var regex := RegEx.new()
|
||||
regex.compile("\\[ext_resource[^\\]]*path=\"([^\"]+)\"")
|
||||
for m in regex.search_all(text):
|
||||
var ref: String = m.get_string(1)
|
||||
if ref.begins_with("uid://"):
|
||||
var id := ResourceUID.text_to_id(ref)
|
||||
if id == -1 or not ResourceUID.has_id(id):
|
||||
issues.append({"path": path, "kind": "missing_uid", "uid": ref})
|
||||
continue
|
||||
if not ResourceLoader.exists(ref):
|
||||
issues.append({"path": path, "kind": "missing_ext_resource", "target": ref})
|
||||
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user