新增godot mcp github copilot
This commit is contained in:
@@ -0,0 +1,518 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"find_unused_resources": _find_unused_resources,
|
||||
"analyze_signal_flow": _analyze_signal_flow,
|
||||
"analyze_scene_complexity": _analyze_scene_complexity,
|
||||
"find_script_references": _find_script_references,
|
||||
"detect_circular_dependencies": _detect_circular_dependencies,
|
||||
"get_project_statistics": _get_project_statistics,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# find_unused_resources
|
||||
# =============================================================================
|
||||
|
||||
## Scan project for resources not referenced by any .tscn, .gd, or .tres file.
|
||||
func _find_unused_resources(params: Dictionary) -> Dictionary:
|
||||
var path: String = optional_string(params, "path", "res://")
|
||||
var include_addons: bool = optional_bool(params, "include_addons", false)
|
||||
|
||||
# Step 1: Collect all resource files
|
||||
var resource_extensions: Array = ["tres", "tscn", "png", "jpg", "jpeg", "svg",
|
||||
"wav", "ogg", "mp3", "ttf", "otf", "gdshader", "material",
|
||||
"theme", "stylebox", "font", "anim"]
|
||||
var all_resources: Array = []
|
||||
_collect_files_by_ext(path, resource_extensions, all_resources, include_addons)
|
||||
|
||||
# Step 2: Collect all referencing files (.tscn, .gd, .tres)
|
||||
var ref_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"]
|
||||
var ref_files: Array = []
|
||||
_collect_files_by_ext(path, ref_extensions, ref_files, include_addons)
|
||||
|
||||
# Step 3: Build a set of all referenced paths
|
||||
var referenced: Dictionary = {} # path -> true
|
||||
for ref_file in ref_files:
|
||||
var content := _read_file_text(ref_file as String)
|
||||
if content.is_empty():
|
||||
continue
|
||||
# Find res:// paths in file content
|
||||
var idx := 0
|
||||
while idx < content.length():
|
||||
var found := content.find("res://", idx)
|
||||
if found == -1:
|
||||
break
|
||||
# Extract the path (up to quote, space, or end of line)
|
||||
var end := found + 6
|
||||
while end < content.length():
|
||||
var c := content[end]
|
||||
if c == '"' or c == "'" or c == ' ' or c == '\n' or c == '\r' or c == ')' or c == ']' or c == '}':
|
||||
break
|
||||
end += 1
|
||||
var ref_path := content.substr(found, end - found)
|
||||
referenced[ref_path] = true
|
||||
idx = end
|
||||
|
||||
# Step 4: Find unreferenced resources
|
||||
var unused: Array = []
|
||||
for res_path in all_resources:
|
||||
var p: String = res_path
|
||||
if not referenced.has(p):
|
||||
# Also check without uid:// prefix variants — some references use uid
|
||||
unused.append(p)
|
||||
|
||||
return success({
|
||||
"unused_resources": unused,
|
||||
"unused_count": unused.size(),
|
||||
"total_resources_scanned": all_resources.size(),
|
||||
"total_files_checked": ref_files.size(),
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# analyze_signal_flow
|
||||
# =============================================================================
|
||||
|
||||
## Map all signal connections in the currently edited scene.
|
||||
func _analyze_signal_flow(params: Dictionary) -> Dictionary:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var nodes_data: Array = []
|
||||
_collect_signal_data(root, root, nodes_data)
|
||||
|
||||
return success({
|
||||
"scene": root.scene_file_path,
|
||||
"nodes": nodes_data,
|
||||
"total_nodes": nodes_data.size(),
|
||||
})
|
||||
|
||||
|
||||
func _collect_signal_data(node: Node, root: Node, out: Array) -> void:
|
||||
var node_path := str(root.get_path_to(node))
|
||||
var signals_emitted: Array = []
|
||||
var signals_connected_to: Array = []
|
||||
|
||||
# Get all signals this node defines
|
||||
for sig in node.get_signal_list():
|
||||
var sig_name: String = sig["name"]
|
||||
var connections := node.get_signal_connection_list(sig_name)
|
||||
if connections.size() > 0:
|
||||
var targets: Array = []
|
||||
for conn in connections:
|
||||
var callable: Callable = conn["callable"]
|
||||
var target_node: Node = callable.get_object() as Node
|
||||
var target_path := ""
|
||||
if target_node != null:
|
||||
target_path = str(root.get_path_to(target_node))
|
||||
targets.append({
|
||||
"target_node": target_path,
|
||||
"method": callable.get_method(),
|
||||
})
|
||||
# Also record on the target side
|
||||
signals_connected_to.append({
|
||||
"from_node": node_path,
|
||||
"signal": sig_name,
|
||||
"method": callable.get_method(),
|
||||
})
|
||||
signals_emitted.append({
|
||||
"signal": sig_name,
|
||||
"targets": targets,
|
||||
})
|
||||
|
||||
# Only include nodes that have signal activity
|
||||
if signals_emitted.size() > 0 or signals_connected_to.size() > 0:
|
||||
out.append({
|
||||
"name": node.name,
|
||||
"path": node_path,
|
||||
"type": node.get_class(),
|
||||
"signals_emitted": signals_emitted,
|
||||
"signals_connected_to": signals_connected_to,
|
||||
})
|
||||
|
||||
for child in node.get_children():
|
||||
_collect_signal_data(child, root, out)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# analyze_scene_complexity
|
||||
# =============================================================================
|
||||
|
||||
## Analyze a scene's complexity: node count, depth, types, scripts, potential issues.
|
||||
func _analyze_scene_complexity(params: Dictionary) -> Dictionary:
|
||||
var scene_path: String = optional_string(params, "path", "")
|
||||
|
||||
var root: Node = null
|
||||
if scene_path.is_empty():
|
||||
root = get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
scene_path = root.scene_file_path
|
||||
else:
|
||||
if not ResourceLoader.exists(scene_path):
|
||||
return error_not_found("Scene '%s'" % scene_path)
|
||||
var packed := ResourceLoader.load(scene_path) as PackedScene
|
||||
if packed == null:
|
||||
return error_internal("Failed to load scene: %s" % scene_path)
|
||||
root = packed.instantiate()
|
||||
|
||||
var total_nodes := 0
|
||||
var max_depth := 0
|
||||
var types: Dictionary = {} # class_name -> count
|
||||
var scripts_attached: Array = []
|
||||
var resources_used: Dictionary = {} # resource path -> count
|
||||
var issues: Array = []
|
||||
|
||||
_analyze_node(root, root, 0, total_nodes, max_depth, types, scripts_attached, resources_used)
|
||||
|
||||
# Count totals from recursive walk
|
||||
total_nodes = _count_nodes_recursive(root)
|
||||
max_depth = _get_max_depth(root, 0)
|
||||
|
||||
# Detect potential issues
|
||||
if total_nodes > 1000:
|
||||
issues.append({"severity": "warning", "message": "Scene has %d nodes (>1000). Consider splitting into sub-scenes." % total_nodes})
|
||||
elif total_nodes > 500:
|
||||
issues.append({"severity": "info", "message": "Scene has %d nodes (>500). Monitor performance." % total_nodes})
|
||||
|
||||
if max_depth > 15:
|
||||
issues.append({"severity": "warning", "message": "Max nesting depth is %d (>15). Deep hierarchies can be hard to maintain." % max_depth})
|
||||
elif max_depth > 10:
|
||||
issues.append({"severity": "info", "message": "Max nesting depth is %d (>10)." % max_depth})
|
||||
|
||||
# If we instantiated the scene ourselves, free it
|
||||
if not scene_path.is_empty() and root != get_edited_root():
|
||||
root.queue_free()
|
||||
|
||||
return success({
|
||||
"scene_path": scene_path,
|
||||
"total_nodes": total_nodes,
|
||||
"max_depth": max_depth,
|
||||
"nodes_by_type": types,
|
||||
"scripts_attached": scripts_attached,
|
||||
"unique_resource_count": resources_used.size(),
|
||||
"issues": issues,
|
||||
})
|
||||
|
||||
|
||||
func _analyze_node(node: Node, root: Node, depth: int,
|
||||
total_nodes: int, max_depth: int,
|
||||
types: Dictionary, scripts: Array, resources: Dictionary) -> void:
|
||||
var type_name := node.get_class()
|
||||
types[type_name] = types.get(type_name, 0) + 1
|
||||
|
||||
if node.get_script() != null:
|
||||
var script: Script = node.get_script()
|
||||
var script_path := script.resource_path
|
||||
if not script_path.is_empty():
|
||||
scripts.append({
|
||||
"node": str(root.get_path_to(node)),
|
||||
"script": script_path,
|
||||
})
|
||||
|
||||
for child in node.get_children():
|
||||
_analyze_node(child, root, depth + 1, total_nodes, max_depth, types, scripts, resources)
|
||||
|
||||
|
||||
func _count_nodes_recursive(node: Node) -> int:
|
||||
var count := 1
|
||||
for child in node.get_children():
|
||||
count += _count_nodes_recursive(child)
|
||||
return count
|
||||
|
||||
|
||||
func _get_max_depth(node: Node, current_depth: int) -> int:
|
||||
var max_d := current_depth
|
||||
for child in node.get_children():
|
||||
var child_depth := _get_max_depth(child, current_depth + 1)
|
||||
if child_depth > max_d:
|
||||
max_d = child_depth
|
||||
return max_d
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# find_script_references
|
||||
# =============================================================================
|
||||
|
||||
## Find all places where a given script, class_name, or resource path is used.
|
||||
func _find_script_references(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "query")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var query: String = result[0]
|
||||
|
||||
var path: String = optional_string(params, "path", "res://")
|
||||
var include_addons: bool = optional_bool(params, "include_addons", false)
|
||||
|
||||
var search_extensions: Array = ["tscn", "gd", "tres", "cfg", "godot"]
|
||||
var search_files: Array = []
|
||||
_collect_files_by_ext(path, search_extensions, search_files, include_addons)
|
||||
|
||||
var references: Array = []
|
||||
for file_path in search_files:
|
||||
var fp: String = file_path
|
||||
var content := _read_file_text(fp)
|
||||
if content.is_empty():
|
||||
continue
|
||||
|
||||
var lines := content.split("\n")
|
||||
var line_num := 0
|
||||
for line in lines:
|
||||
line_num += 1
|
||||
var l: String = line
|
||||
if l.contains(query):
|
||||
references.append({
|
||||
"file": fp,
|
||||
"line": line_num,
|
||||
"content": l.strip_edges(),
|
||||
})
|
||||
|
||||
return success({
|
||||
"query": query,
|
||||
"references": references,
|
||||
"reference_count": references.size(),
|
||||
"files_searched": search_files.size(),
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# detect_circular_dependencies
|
||||
# =============================================================================
|
||||
|
||||
## Check for circular scene dependencies (.tscn files referencing each other).
|
||||
func _detect_circular_dependencies(params: Dictionary) -> Dictionary:
|
||||
var path: String = optional_string(params, "path", "res://")
|
||||
var include_addons: bool = optional_bool(params, "include_addons", false)
|
||||
|
||||
# Step 1: Collect all .tscn files
|
||||
var tscn_files: Array = []
|
||||
_collect_files_by_ext(path, ["tscn"], tscn_files, include_addons)
|
||||
|
||||
# Step 2: Build dependency graph: scene_path -> [referenced_scene_paths]
|
||||
var dep_graph: Dictionary = {} # String -> Array[String]
|
||||
for tscn_path in tscn_files:
|
||||
var tp: String = tscn_path
|
||||
var content := _read_file_text(tp)
|
||||
if content.is_empty():
|
||||
continue
|
||||
|
||||
var deps: Array = []
|
||||
for line in content.split("\n"):
|
||||
var l: String = line
|
||||
# Match [ext_resource ... path="res://..." ...] lines that reference .tscn
|
||||
if l.begins_with("[ext_resource") and ".tscn" in l:
|
||||
var path_start := l.find('path="')
|
||||
if path_start == -1:
|
||||
continue
|
||||
path_start += 6 # len('path="')
|
||||
var path_end := l.find('"', path_start)
|
||||
if path_end == -1:
|
||||
continue
|
||||
var ref_path := l.substr(path_start, path_end - path_start)
|
||||
if ref_path.ends_with(".tscn"):
|
||||
deps.append(ref_path)
|
||||
dep_graph[tp] = deps
|
||||
|
||||
# Step 3: Detect cycles using DFS
|
||||
var cycles: Array = []
|
||||
var visited: Dictionary = {} # path -> "unvisited" | "visiting" | "visited"
|
||||
for scene in dep_graph:
|
||||
visited[scene] = "unvisited"
|
||||
|
||||
for scene in dep_graph:
|
||||
if visited[scene] == "unvisited":
|
||||
var path_stack: Array = []
|
||||
_dfs_detect_cycle(scene as String, dep_graph, visited, path_stack, cycles)
|
||||
|
||||
return success({
|
||||
"scenes_checked": tscn_files.size(),
|
||||
"circular_dependencies": cycles,
|
||||
"has_circular": cycles.size() > 0,
|
||||
"dependency_graph": dep_graph,
|
||||
})
|
||||
|
||||
|
||||
func _dfs_detect_cycle(node: String, graph: Dictionary, visited: Dictionary,
|
||||
path_stack: Array, cycles: Array) -> void:
|
||||
visited[node] = "visiting"
|
||||
path_stack.append(node)
|
||||
|
||||
if graph.has(node):
|
||||
var deps: Array = graph[node]
|
||||
for dep in deps:
|
||||
var d: String = dep
|
||||
if not visited.has(d):
|
||||
# Scene referenced but not in our graph (might not exist or outside scope)
|
||||
continue
|
||||
if visited[d] == "visiting":
|
||||
# Found a cycle — extract it from the stack
|
||||
var cycle_start := path_stack.find(d)
|
||||
var cycle: Array = path_stack.slice(cycle_start)
|
||||
cycle.append(d) # Close the cycle
|
||||
cycles.append(cycle)
|
||||
elif visited[d] == "unvisited":
|
||||
_dfs_detect_cycle(d, graph, visited, path_stack, cycles)
|
||||
|
||||
path_stack.pop_back()
|
||||
visited[node] = "visited"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# get_project_statistics
|
||||
# =============================================================================
|
||||
|
||||
## Overall project stats: file counts, script lines, scenes, resources, autoloads, plugins.
|
||||
func _get_project_statistics(params: Dictionary) -> Dictionary:
|
||||
var path: String = optional_string(params, "path", "res://")
|
||||
var include_addons: bool = optional_bool(params, "include_addons", false)
|
||||
|
||||
var file_counts: Dictionary = {} # extension -> count
|
||||
var total_script_lines := 0
|
||||
var scene_count := 0
|
||||
var resource_count := 0
|
||||
var total_files := 0
|
||||
|
||||
_collect_statistics(path, include_addons, file_counts)
|
||||
|
||||
# Extract internal counters and remove them from the visible dict
|
||||
total_script_lines = int(file_counts.get("_total_script_lines", 0))
|
||||
scene_count = int(file_counts.get("_scene_count", 0))
|
||||
resource_count = int(file_counts.get("_resource_count", 0))
|
||||
total_files = int(file_counts.get("_total_files", 0))
|
||||
file_counts.erase("_total_script_lines")
|
||||
file_counts.erase("_scene_count")
|
||||
file_counts.erase("_resource_count")
|
||||
file_counts.erase("_total_files")
|
||||
|
||||
# Autoloads
|
||||
var autoloads: Dictionary = {}
|
||||
for prop in ProjectSettings.get_property_list():
|
||||
var prop_name: String = prop["name"]
|
||||
if prop_name.begins_with("autoload/"):
|
||||
autoloads[prop_name.substr(9)] = str(ProjectSettings.get_setting(prop_name))
|
||||
|
||||
# Enabled plugins
|
||||
var plugins: Array = []
|
||||
var plugin_cfg_path := "res://addons"
|
||||
var enabled_plugins: PackedStringArray = ProjectSettings.get_setting(
|
||||
"editor_plugins/enabled", PackedStringArray()
|
||||
)
|
||||
var plugin_dir := DirAccess.open(plugin_cfg_path)
|
||||
if plugin_dir != null:
|
||||
plugin_dir.list_dir_begin()
|
||||
var dir_name := plugin_dir.get_next()
|
||||
while not dir_name.is_empty():
|
||||
if plugin_dir.current_is_dir() and not dir_name.begins_with("."):
|
||||
var cfg_path := plugin_cfg_path.path_join(dir_name).path_join("plugin.cfg")
|
||||
if FileAccess.file_exists(cfg_path):
|
||||
var plugin_path := "res://addons/%s/plugin.cfg" % dir_name
|
||||
plugins.append({
|
||||
"name": dir_name,
|
||||
"enabled": plugin_path in enabled_plugins,
|
||||
})
|
||||
dir_name = plugin_dir.get_next()
|
||||
plugin_dir.list_dir_end()
|
||||
|
||||
return success({
|
||||
"file_counts_by_extension": file_counts,
|
||||
"total_files": total_files,
|
||||
"total_script_lines": total_script_lines,
|
||||
"scene_count": scene_count,
|
||||
"resource_count": resource_count,
|
||||
"autoloads": autoloads,
|
||||
"plugins": plugins,
|
||||
})
|
||||
|
||||
|
||||
func _collect_statistics(path: String, include_addons: bool,
|
||||
file_counts: Dictionary) -> void:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
|
||||
while not file_name.is_empty():
|
||||
if file_name.begins_with("."):
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(file_name)
|
||||
|
||||
if dir.current_is_dir():
|
||||
if file_name == "addons" and not include_addons:
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
_collect_statistics(full_path, include_addons, file_counts)
|
||||
else:
|
||||
var ext := file_name.get_extension().to_lower()
|
||||
file_counts[ext] = file_counts.get(ext, 0) + 1
|
||||
|
||||
if ext == "gd":
|
||||
var content := _read_file_text(full_path)
|
||||
var line_count := content.count("\n") + 1 if not content.is_empty() else 0
|
||||
# We can't modify int params, so we store in the dict
|
||||
file_counts["_total_script_lines"] = file_counts.get("_total_script_lines", 0) + line_count
|
||||
|
||||
if ext == "tscn":
|
||||
file_counts["_scene_count"] = file_counts.get("_scene_count", 0) + 1
|
||||
|
||||
if ext in ["tres", "material", "theme", "stylebox", "font"]:
|
||||
file_counts["_resource_count"] = file_counts.get("_resource_count", 0) + 1
|
||||
|
||||
file_counts["_total_files"] = file_counts.get("_total_files", 0) + 1
|
||||
|
||||
file_name = dir.get_next()
|
||||
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Shared helpers
|
||||
# =============================================================================
|
||||
|
||||
## Recursively collect files matching given extensions.
|
||||
func _collect_files_by_ext(path: String, extensions: Array, out: Array, include_addons: bool) -> void:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
|
||||
while not file_name.is_empty():
|
||||
if file_name.begins_with("."):
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(file_name)
|
||||
|
||||
if dir.current_is_dir():
|
||||
if file_name == "addons" and not include_addons:
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
_collect_files_by_ext(full_path, extensions, out, include_addons)
|
||||
else:
|
||||
var ext := file_name.get_extension().to_lower()
|
||||
if ext in extensions:
|
||||
out.append(full_path)
|
||||
|
||||
file_name = dir.get_next()
|
||||
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
## Read a file's text content. Returns empty string on failure.
|
||||
func _read_file_text(file_path: String) -> String:
|
||||
var file := FileAccess.open(file_path, FileAccess.READ)
|
||||
if file == null:
|
||||
return ""
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
return content
|
||||
@@ -0,0 +1,192 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"list_android_devices": _list_android_devices,
|
||||
"get_android_preset_info": _get_android_preset_info,
|
||||
"deploy_to_android": _deploy_to_android,
|
||||
}
|
||||
|
||||
|
||||
## Resolve adb path from editor settings or PATH fallback.
|
||||
func _resolve_adb_path() -> String:
|
||||
var editor_settings := get_editor().get_editor_settings()
|
||||
# Godot exposes this under export/android/adb (may be stored as an absolute path).
|
||||
var configured: String = ""
|
||||
if editor_settings.has_setting("export/android/adb"):
|
||||
configured = str(editor_settings.get_setting("export/android/adb"))
|
||||
if not configured.is_empty() and FileAccess.file_exists(configured):
|
||||
return configured
|
||||
# Fallback: assume adb is on PATH. OS.execute will resolve it at call time.
|
||||
return "adb"
|
||||
|
||||
|
||||
func _run(cmd: String, args: PackedStringArray) -> Dictionary:
|
||||
var output: Array = []
|
||||
var exit_code := OS.execute(cmd, args, output, true)
|
||||
var stdout := ""
|
||||
if not output.is_empty():
|
||||
stdout = str(output[0])
|
||||
return {"exit_code": exit_code, "stdout": stdout}
|
||||
|
||||
|
||||
## List devices visible to adb.
|
||||
func _list_android_devices(_params: Dictionary) -> Dictionary:
|
||||
var adb := _resolve_adb_path()
|
||||
var result := _run(adb, PackedStringArray(["devices", "-l"]))
|
||||
if result["exit_code"] != 0:
|
||||
return error(-32000, "adb failed (exit %d). Install Android platform-tools or set Editor Settings > Export > Android > Adb." % result["exit_code"], {"adb_path": adb, "output": result["stdout"]})
|
||||
|
||||
# Parse `adb devices -l` output:
|
||||
# List of devices attached
|
||||
# R58M12345 device usb:3-1 product:foo model:Pixel_5 device:redfin
|
||||
var devices: Array = []
|
||||
var lines: PackedStringArray = str(result["stdout"]).split("\n")
|
||||
for raw_line in lines:
|
||||
var line: String = raw_line.strip_edges()
|
||||
if line.is_empty() or line.begins_with("List of devices") or line.begins_with("* daemon"):
|
||||
continue
|
||||
var parts: PackedStringArray = line.split(" ", false)
|
||||
if parts.size() < 2:
|
||||
continue
|
||||
var dev: Dictionary = {"serial": parts[0], "state": parts[1]}
|
||||
for i in range(2, parts.size()):
|
||||
var kv: String = parts[i]
|
||||
var eq: int = kv.find(":")
|
||||
if eq > 0:
|
||||
dev[kv.substr(0, eq)] = kv.substr(eq + 1)
|
||||
devices.append(dev)
|
||||
|
||||
return success({"devices": devices, "count": devices.size(), "adb_path": adb})
|
||||
|
||||
|
||||
## Find an Android preset in export_presets.cfg. Returns the preset dict or null.
|
||||
func _find_android_preset(preset_name: String, preset_index: int) -> Dictionary:
|
||||
var presets_path := "res://export_presets.cfg"
|
||||
if not FileAccess.file_exists(presets_path):
|
||||
return {}
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(presets_path) != OK:
|
||||
return {}
|
||||
|
||||
var idx := 0
|
||||
while cfg.has_section("preset.%d" % idx):
|
||||
var section := "preset.%d" % idx
|
||||
var platform := str(cfg.get_value(section, "platform", ""))
|
||||
var name := str(cfg.get_value(section, "name", ""))
|
||||
var matches := false
|
||||
if not preset_name.is_empty():
|
||||
matches = (name == preset_name)
|
||||
elif preset_index >= 0:
|
||||
matches = (idx == preset_index)
|
||||
else:
|
||||
# No filter: pick the first Android preset.
|
||||
matches = (platform == "Android")
|
||||
if matches:
|
||||
var options_section := "preset.%d.options" % idx
|
||||
var package_name := ""
|
||||
if cfg.has_section(options_section):
|
||||
package_name = str(cfg.get_value(options_section, "package/unique_name", ""))
|
||||
return {
|
||||
"index": idx,
|
||||
"name": name,
|
||||
"platform": platform,
|
||||
"runnable": bool(cfg.get_value(section, "runnable", false)),
|
||||
"export_path": str(cfg.get_value(section, "export_path", "")),
|
||||
"package_name": package_name,
|
||||
}
|
||||
idx += 1
|
||||
return {}
|
||||
|
||||
|
||||
## Read Android preset metadata (package name, export path, etc.)
|
||||
func _get_android_preset_info(params: Dictionary) -> Dictionary:
|
||||
var preset_name: String = optional_string(params, "preset_name", "")
|
||||
var preset_index: int = optional_int(params, "preset_index", -1)
|
||||
var preset := _find_android_preset(preset_name, preset_index)
|
||||
if preset.is_empty():
|
||||
return error_not_found("Android export preset", "Configure an Android preset in Project > Export first.")
|
||||
if preset["platform"] != "Android":
|
||||
return error(-32000, "Preset '%s' is not an Android preset (platform=%s)" % [preset["name"], preset["platform"]])
|
||||
return success(preset)
|
||||
|
||||
|
||||
## Export APK, install it on a device, then optionally launch the main activity.
|
||||
func _deploy_to_android(params: Dictionary) -> Dictionary:
|
||||
var preset_name: String = optional_string(params, "preset_name", "")
|
||||
var preset_index: int = optional_int(params, "preset_index", -1)
|
||||
var device_serial: String = optional_string(params, "device_serial", "")
|
||||
var debug: bool = optional_bool(params, "debug", true)
|
||||
var launch: bool = optional_bool(params, "launch", true)
|
||||
var skip_export: bool = optional_bool(params, "skip_export", false)
|
||||
|
||||
var preset := _find_android_preset(preset_name, preset_index)
|
||||
if preset.is_empty():
|
||||
return error_not_found("Android export preset", "Configure an Android preset in Project > Export first.")
|
||||
if preset["platform"] != "Android":
|
||||
return error(-32000, "Preset '%s' is not an Android preset" % preset["name"])
|
||||
|
||||
var export_path_res: String = preset["export_path"]
|
||||
if export_path_res.is_empty():
|
||||
return error(-32000, "Export path not configured for preset '%s'" % preset["name"])
|
||||
var export_path_abs: String = ProjectSettings.globalize_path(export_path_res) if export_path_res.begins_with("res://") else export_path_res
|
||||
|
||||
var steps: Array = []
|
||||
|
||||
# Step 1: Export APK via Godot CLI (unless caller already has an APK).
|
||||
if not skip_export:
|
||||
var godot_bin := OS.get_executable_path()
|
||||
var project_dir := ProjectSettings.globalize_path("res://")
|
||||
var export_flag := "--export-debug" if debug else "--export-release"
|
||||
var export_args := PackedStringArray(["--headless", "--path", project_dir, export_flag, preset["name"], export_path_abs])
|
||||
var export_result := _run(godot_bin, export_args)
|
||||
steps.append({"step": "export", "command": godot_bin, "args": export_args, "exit_code": export_result["exit_code"]})
|
||||
if export_result["exit_code"] != 0:
|
||||
return error(-32000, "Godot export failed (exit %d). See stdout." % export_result["exit_code"], {"steps": steps, "stdout": export_result["stdout"]})
|
||||
|
||||
if not FileAccess.file_exists(export_path_abs):
|
||||
return error(-32000, "APK not found at %s after export" % export_path_abs, {"steps": steps})
|
||||
|
||||
# Step 2: adb install -r
|
||||
var adb := _resolve_adb_path()
|
||||
var install_args := PackedStringArray()
|
||||
if not device_serial.is_empty():
|
||||
install_args.append("-s")
|
||||
install_args.append(device_serial)
|
||||
install_args.append("install")
|
||||
install_args.append("-r")
|
||||
install_args.append(export_path_abs)
|
||||
var install_result := _run(adb, install_args)
|
||||
steps.append({"step": "install", "command": adb, "args": install_args, "exit_code": install_result["exit_code"], "stdout": install_result["stdout"]})
|
||||
if install_result["exit_code"] != 0:
|
||||
return error(-32000, "adb install failed (exit %d)" % install_result["exit_code"], {"steps": steps})
|
||||
|
||||
# Step 3: adb shell am start (optional)
|
||||
if launch:
|
||||
var package_name: String = preset["package_name"]
|
||||
if package_name.is_empty():
|
||||
steps.append({"step": "launch", "skipped": true, "reason": "package_name not found in preset"})
|
||||
else:
|
||||
var launch_args := PackedStringArray()
|
||||
if not device_serial.is_empty():
|
||||
launch_args.append("-s")
|
||||
launch_args.append(device_serial)
|
||||
launch_args.append("shell")
|
||||
launch_args.append("monkey")
|
||||
launch_args.append("-p")
|
||||
launch_args.append(package_name)
|
||||
launch_args.append("-c")
|
||||
launch_args.append("android.intent.category.LAUNCHER")
|
||||
launch_args.append("1")
|
||||
var launch_result := _run(adb, launch_args)
|
||||
steps.append({"step": "launch", "command": adb, "args": launch_args, "exit_code": launch_result["exit_code"], "stdout": launch_result["stdout"]})
|
||||
|
||||
return success({
|
||||
"preset": preset["name"],
|
||||
"apk_path": export_path_abs,
|
||||
"device": device_serial if not device_serial.is_empty() else "(default)",
|
||||
"package_name": preset["package_name"],
|
||||
"steps": steps,
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"list_animations": _list_animations,
|
||||
"create_animation": _create_animation,
|
||||
"add_animation_track": _add_animation_track,
|
||||
"set_animation_keyframe": _set_animation_keyframe,
|
||||
"get_animation_info": _get_animation_info,
|
||||
"remove_animation": _remove_animation,
|
||||
}
|
||||
|
||||
|
||||
func _find_animation_player(node_path: String) -> AnimationPlayer:
|
||||
var node := find_node_by_path(node_path)
|
||||
if node is AnimationPlayer:
|
||||
return node as AnimationPlayer
|
||||
return null
|
||||
|
||||
|
||||
func _list_animations(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var player := _find_animation_player(node_path)
|
||||
if player == null:
|
||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
||||
|
||||
var animations: Array = []
|
||||
for anim_name in player.get_animation_list():
|
||||
var anim := player.get_animation(anim_name)
|
||||
animations.append({
|
||||
"name": anim_name,
|
||||
"length": anim.length,
|
||||
"loop_mode": anim.loop_mode,
|
||||
"track_count": anim.get_track_count(),
|
||||
})
|
||||
|
||||
return success({"node_path": node_path, "animations": animations, "count": animations.size()})
|
||||
|
||||
|
||||
func _create_animation(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var anim_name: String = result2[0]
|
||||
|
||||
var player := _find_animation_player(node_path)
|
||||
if player == null:
|
||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
||||
|
||||
var length: float = float(params.get("length", 1.0))
|
||||
var loop_mode: int = int(params.get("loop_mode", 0)) # 0=none, 1=linear, 2=pingpong
|
||||
|
||||
var anim := Animation.new()
|
||||
anim.length = length
|
||||
anim.loop_mode = loop_mode as Animation.LoopMode
|
||||
|
||||
var lib := player.get_animation_library("")
|
||||
var created_library := false
|
||||
if lib == null:
|
||||
lib = AnimationLibrary.new()
|
||||
created_library = true
|
||||
|
||||
if lib.has_animation(anim_name):
|
||||
return error_invalid_params("Animation '%s' already exists" % anim_name)
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Create animation %s" % anim_name)
|
||||
if created_library:
|
||||
undo_redo.add_do_method(player, "add_animation_library", "", lib)
|
||||
undo_redo.add_do_reference(lib)
|
||||
undo_redo.add_undo_method(player, "remove_animation_library", "")
|
||||
undo_redo.add_do_method(lib, "add_animation", anim_name, anim)
|
||||
undo_redo.add_do_reference(anim)
|
||||
undo_redo.add_undo_method(lib, "remove_animation", anim_name)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"name": anim_name, "length": length, "created": true})
|
||||
|
||||
|
||||
func _add_animation_track(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "animation")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var anim_name: String = result2[0]
|
||||
|
||||
var result3 := require_string(params, "track_path")
|
||||
if result3[1] != null:
|
||||
return result3[1]
|
||||
var track_path: String = result3[0]
|
||||
|
||||
var player := _find_animation_player(node_path)
|
||||
if player == null:
|
||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
||||
|
||||
var anim := player.get_animation(anim_name)
|
||||
if anim == null:
|
||||
return error_not_found("Animation '%s'" % anim_name)
|
||||
|
||||
var track_type_str: String = optional_string(params, "track_type", "value")
|
||||
var track_type: int
|
||||
match track_type_str:
|
||||
"value": track_type = Animation.TYPE_VALUE
|
||||
"position_2d": track_type = Animation.TYPE_POSITION_3D # Godot uses 3D type for 2D too
|
||||
"rotation_2d": track_type = Animation.TYPE_ROTATION_3D
|
||||
"scale_2d": track_type = Animation.TYPE_SCALE_3D
|
||||
"method": track_type = Animation.TYPE_METHOD
|
||||
"bezier": track_type = Animation.TYPE_BEZIER
|
||||
"blend_shape": track_type = Animation.TYPE_BLEND_SHAPE
|
||||
_: track_type = Animation.TYPE_VALUE
|
||||
|
||||
var track_idx := anim.get_track_count()
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Add animation track")
|
||||
undo_redo.add_do_method(anim, "add_track", track_type, track_idx)
|
||||
undo_redo.add_do_method(anim, "track_set_path", track_idx, NodePath(track_path))
|
||||
|
||||
var update_mode_str: String = optional_string(params, "update_mode", "")
|
||||
if not update_mode_str.is_empty() and track_type == Animation.TYPE_VALUE:
|
||||
match update_mode_str:
|
||||
"continuous": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_CONTINUOUS)
|
||||
"discrete": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_DISCRETE)
|
||||
"capture": undo_redo.add_do_method(anim, "value_track_set_update_mode", track_idx, Animation.UPDATE_CAPTURE)
|
||||
undo_redo.add_undo_method(anim, "remove_track", track_idx)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"track_index": track_idx, "track_path": track_path, "track_type": track_type_str})
|
||||
|
||||
|
||||
func _set_animation_keyframe(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "animation")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var anim_name: String = result2[0]
|
||||
|
||||
var player := _find_animation_player(node_path)
|
||||
if player == null:
|
||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
||||
|
||||
var anim := player.get_animation(anim_name)
|
||||
if anim == null:
|
||||
return error_not_found("Animation '%s'" % anim_name)
|
||||
|
||||
var track_index: int = int(params.get("track_index", 0))
|
||||
if track_index < 0 or track_index >= anim.get_track_count():
|
||||
return error_invalid_params("Invalid track_index: %d" % track_index)
|
||||
|
||||
var time: float = float(params.get("time", 0.0))
|
||||
var value = params.get("value")
|
||||
|
||||
# Parse value string for common types
|
||||
if value is String:
|
||||
var s: String = value
|
||||
var expr := Expression.new()
|
||||
if expr.parse(s) == OK:
|
||||
var parsed = expr.execute()
|
||||
if parsed != null:
|
||||
value = parsed
|
||||
|
||||
var easing: float = float(params.get("easing", 1.0))
|
||||
var old_key_idx := _find_animation_key_at_time(anim, track_index, time)
|
||||
var had_old_key := old_key_idx >= 0
|
||||
var old_value: Variant = anim.track_get_key_value(track_index, old_key_idx) if had_old_key else null
|
||||
var old_easing: float = anim.track_get_key_transition(track_index, old_key_idx) if had_old_key else 1.0
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set animation keyframe")
|
||||
undo_redo.add_do_method(self, "_upsert_animation_key", anim, track_index, time, value, easing)
|
||||
undo_redo.add_undo_method(self, "_restore_animation_key", anim, track_index, time, had_old_key, old_value, old_easing)
|
||||
undo_redo.commit_action()
|
||||
|
||||
var key_idx := _find_animation_key_at_time(anim, track_index, time)
|
||||
|
||||
return success({"track_index": track_index, "time": time, "key_index": key_idx, "easing": anim.track_get_key_transition(track_index, key_idx)})
|
||||
|
||||
|
||||
func _get_animation_info(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "animation")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var anim_name: String = result2[0]
|
||||
|
||||
var player := _find_animation_player(node_path)
|
||||
if player == null:
|
||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
||||
|
||||
var anim := player.get_animation(anim_name)
|
||||
if anim == null:
|
||||
return error_not_found("Animation '%s'" % anim_name)
|
||||
|
||||
var tracks: Array = []
|
||||
for i in anim.get_track_count():
|
||||
var track_info := {
|
||||
"index": i,
|
||||
"path": str(anim.track_get_path(i)),
|
||||
"type": anim.track_get_type(i),
|
||||
"key_count": anim.track_get_key_count(i),
|
||||
}
|
||||
var keys: Array = []
|
||||
for k in anim.track_get_key_count(i):
|
||||
keys.append({
|
||||
"time": anim.track_get_key_time(i, k),
|
||||
"value": str(anim.track_get_key_value(i, k)),
|
||||
"easing": anim.track_get_key_transition(i, k),
|
||||
})
|
||||
track_info["keys"] = keys
|
||||
tracks.append(track_info)
|
||||
|
||||
return success({
|
||||
"name": anim_name,
|
||||
"length": anim.length,
|
||||
"loop_mode": anim.loop_mode,
|
||||
"step": anim.step,
|
||||
"tracks": tracks,
|
||||
})
|
||||
|
||||
|
||||
func _remove_animation(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var anim_name: String = result2[0]
|
||||
|
||||
var player := _find_animation_player(node_path)
|
||||
if player == null:
|
||||
return error_not_found("AnimationPlayer at '%s'" % node_path)
|
||||
|
||||
var lib := player.get_animation_library("")
|
||||
if lib == null or not lib.has_animation(anim_name):
|
||||
return error_not_found("Animation '%s'" % anim_name)
|
||||
|
||||
var anim := lib.get_animation(anim_name)
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Remove animation %s" % anim_name)
|
||||
undo_redo.add_do_method(lib, "remove_animation", anim_name)
|
||||
undo_redo.add_undo_method(lib, "add_animation", anim_name, anim)
|
||||
undo_redo.add_undo_reference(anim)
|
||||
undo_redo.commit_action()
|
||||
return success({"name": anim_name, "removed": true})
|
||||
|
||||
|
||||
func _find_animation_key_at_time(anim: Animation, track_index: int, time: float) -> int:
|
||||
for key_index: int in anim.track_get_key_count(track_index):
|
||||
if is_equal_approx(anim.track_get_key_time(track_index, key_index), time):
|
||||
return key_index
|
||||
return -1
|
||||
|
||||
|
||||
func _upsert_animation_key(anim: Animation, track_index: int, time: float, value: Variant, easing: float) -> void:
|
||||
var key_idx := _find_animation_key_at_time(anim, track_index, time)
|
||||
if key_idx < 0:
|
||||
key_idx = anim.track_insert_key(track_index, time, value)
|
||||
else:
|
||||
anim.track_set_key_value(track_index, key_idx, value)
|
||||
if easing != 1.0:
|
||||
anim.track_set_key_transition(track_index, key_idx, easing)
|
||||
|
||||
|
||||
func _restore_animation_key(anim: Animation, track_index: int, time: float, had_old_key: bool, old_value: Variant, old_easing: float) -> void:
|
||||
var key_idx := _find_animation_key_at_time(anim, track_index, time)
|
||||
if had_old_key:
|
||||
if key_idx < 0:
|
||||
key_idx = anim.track_insert_key(track_index, time, old_value)
|
||||
else:
|
||||
anim.track_set_key_value(track_index, key_idx, old_value)
|
||||
anim.track_set_key_transition(track_index, key_idx, old_easing)
|
||||
elif key_idx >= 0:
|
||||
anim.track_remove_key(track_index, key_idx)
|
||||
@@ -0,0 +1,601 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"create_animation_tree": _create_animation_tree,
|
||||
"get_animation_tree_structure": _get_animation_tree_structure,
|
||||
"add_state_machine_state": _add_state_machine_state,
|
||||
"remove_state_machine_state": _remove_state_machine_state,
|
||||
"add_state_machine_transition": _add_state_machine_transition,
|
||||
"remove_state_machine_transition": _remove_state_machine_transition,
|
||||
"set_blend_tree_node": _set_blend_tree_node,
|
||||
"set_tree_parameter": _set_tree_parameter,
|
||||
}
|
||||
|
||||
|
||||
## Find AnimationTree on a node or return null
|
||||
func _find_animation_tree(node_path: String) -> AnimationTree:
|
||||
var node := find_node_by_path(node_path)
|
||||
if node is AnimationTree:
|
||||
return node as AnimationTree
|
||||
return null
|
||||
|
||||
|
||||
## Navigate to a nested state machine by slash-separated path (e.g. "Run/SubState")
|
||||
## Returns [state_machine, error_or_null]
|
||||
func _resolve_state_machine(tree: AnimationTree, sm_path: String) -> Array:
|
||||
var root := tree.tree_root
|
||||
if not root is AnimationNodeStateMachine:
|
||||
return [null, error_invalid_params("AnimationTree root is not an AnimationNodeStateMachine")]
|
||||
|
||||
if sm_path.is_empty() or sm_path == ".":
|
||||
return [root as AnimationNodeStateMachine, null]
|
||||
|
||||
var current: AnimationNodeStateMachine = root as AnimationNodeStateMachine
|
||||
var parts := sm_path.split("/")
|
||||
for part in parts:
|
||||
if not current.has_node(StringName(part)):
|
||||
return [null, error_not_found("State machine node '%s' in path '%s'" % [part, sm_path])]
|
||||
var child := current.get_node(StringName(part))
|
||||
if not child is AnimationNodeStateMachine:
|
||||
return [null, error_invalid_params("Node '%s' is not a StateMachine" % part)]
|
||||
current = child as AnimationNodeStateMachine
|
||||
return [current, null]
|
||||
|
||||
|
||||
## Resolve a BlendTree inside the tree. bt_path can be a state name inside a state machine,
|
||||
## or a slash-separated path. The last segment is the BlendTree node name.
|
||||
## Returns [blend_tree, error_or_null]
|
||||
func _resolve_blend_tree(tree: AnimationTree, sm_path: String, bt_name: String) -> Array:
|
||||
var result := _resolve_state_machine(tree, sm_path)
|
||||
if result[1] != null:
|
||||
return result
|
||||
|
||||
var sm: AnimationNodeStateMachine = result[0]
|
||||
if not sm.has_node(StringName(bt_name)):
|
||||
return [null, error_not_found("BlendTree node '%s'" % bt_name)]
|
||||
|
||||
var node := sm.get_node(StringName(bt_name))
|
||||
if not node is AnimationNodeBlendTree:
|
||||
return [null, error_invalid_params("Node '%s' is not an AnimationNodeBlendTree" % bt_name)]
|
||||
|
||||
return [node as AnimationNodeBlendTree, null]
|
||||
|
||||
|
||||
func _create_animation_tree(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var parent := find_node_by_path(node_path)
|
||||
if parent == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
|
||||
var anim_player_path: String = optional_string(params, "anim_player", "")
|
||||
var tree_name: String = optional_string(params, "name", "AnimationTree")
|
||||
|
||||
# Create the AnimationTree
|
||||
var tree := AnimationTree.new()
|
||||
tree.name = tree_name
|
||||
|
||||
# Set root to AnimationNodeStateMachine
|
||||
var state_machine := AnimationNodeStateMachine.new()
|
||||
tree.tree_root = state_machine
|
||||
|
||||
# Link to AnimationPlayer if provided
|
||||
if not anim_player_path.is_empty():
|
||||
tree.anim_player = NodePath(anim_player_path)
|
||||
|
||||
add_child_with_undo(parent, tree, root, "MCP: Create AnimationTree")
|
||||
|
||||
return success({
|
||||
"name": tree.name,
|
||||
"node_path": str(root.get_path_to(tree)),
|
||||
"root_type": "AnimationNodeStateMachine",
|
||||
"anim_player": anim_player_path,
|
||||
"created": true,
|
||||
})
|
||||
|
||||
|
||||
func _get_animation_tree_structure(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var tree := _find_animation_tree(node_path)
|
||||
if tree == null:
|
||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
||||
|
||||
var root := tree.tree_root
|
||||
if root == null:
|
||||
return success({"node_path": node_path, "root": null})
|
||||
|
||||
var structure := _read_node_structure(root)
|
||||
structure["active"] = tree.active
|
||||
structure["anim_player"] = str(tree.anim_player)
|
||||
structure["node_path"] = node_path
|
||||
|
||||
return success(structure)
|
||||
|
||||
|
||||
func _read_node_structure(node: AnimationNode) -> Dictionary:
|
||||
if node is AnimationNodeStateMachine:
|
||||
return _read_state_machine_structure(node as AnimationNodeStateMachine)
|
||||
elif node is AnimationNodeBlendTree:
|
||||
return _read_blend_tree_structure(node as AnimationNodeBlendTree)
|
||||
elif node is AnimationNodeAnimation:
|
||||
var anim_node := node as AnimationNodeAnimation
|
||||
return {"type": "AnimationNodeAnimation", "animation": str(anim_node.animation)}
|
||||
else:
|
||||
return {"type": node.get_class()}
|
||||
|
||||
|
||||
func _read_state_machine_structure(sm: AnimationNodeStateMachine) -> Dictionary:
|
||||
var states: Array = []
|
||||
# Iterate through graph nodes via get_node_name
|
||||
# AnimationNodeStateMachine doesn't have get_node_list in 4.x, iterate using _get_child_nodes
|
||||
var node_list := _get_sm_node_names(sm)
|
||||
for state_name in node_list:
|
||||
var child := sm.get_node(StringName(state_name))
|
||||
var state_info := {
|
||||
"name": state_name,
|
||||
"position": {"x": sm.get_node_position(StringName(state_name)).x, "y": sm.get_node_position(StringName(state_name)).y},
|
||||
}
|
||||
state_info.merge(_read_node_structure(child))
|
||||
states.append(state_info)
|
||||
|
||||
var transitions: Array = []
|
||||
for i in sm.get_transition_count():
|
||||
var from_node := sm.get_transition_from(i)
|
||||
var to_node := sm.get_transition_to(i)
|
||||
var trans := sm.get_transition(i)
|
||||
var trans_info := {
|
||||
"from": str(from_node),
|
||||
"to": str(to_node),
|
||||
"switch_mode": trans.switch_mode,
|
||||
"advance_mode": trans.advance_mode,
|
||||
}
|
||||
if not trans.advance_expression.is_empty():
|
||||
trans_info["advance_expression"] = trans.advance_expression
|
||||
if trans.advance_mode == AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO:
|
||||
trans_info["auto"] = true
|
||||
transitions.append(trans_info)
|
||||
|
||||
return {
|
||||
"type": "AnimationNodeStateMachine",
|
||||
"states": states,
|
||||
"transitions": transitions,
|
||||
}
|
||||
|
||||
|
||||
func _get_sm_node_names(sm: AnimationNodeStateMachine) -> Array:
|
||||
# Use the internal _get_child_nodes or iterate known patterns
|
||||
# AnimationNodeStateMachine doesn't expose a simple list method,
|
||||
# but we can use get_graph_offset and iterate via has_node with common checks.
|
||||
# Actually in Godot 4.x we can get the node list by checking property list
|
||||
# or using the script resource approach. The most reliable is iterating through
|
||||
# the resource properties.
|
||||
var names: Array = []
|
||||
var prop_list := sm.get_property_list()
|
||||
for prop in prop_list:
|
||||
var pname: String = prop["name"]
|
||||
# State machine stores nodes as "states/<name>/node"
|
||||
if pname.begins_with("states/") and pname.ends_with("/node"):
|
||||
var state_name := pname.get_slice("/", 1)
|
||||
if state_name != "Start" and state_name != "End":
|
||||
names.append(state_name)
|
||||
return names
|
||||
|
||||
|
||||
func _read_blend_tree_structure(bt: AnimationNodeBlendTree) -> Dictionary:
|
||||
var nodes_info: Array = []
|
||||
var prop_list := bt.get_property_list()
|
||||
var node_names: Array = []
|
||||
for prop in prop_list:
|
||||
var pname: String = prop["name"]
|
||||
if pname.begins_with("nodes/") and pname.ends_with("/node"):
|
||||
var n := pname.get_slice("/", 1)
|
||||
if n != "output":
|
||||
node_names.append(n)
|
||||
|
||||
for n_name in node_names:
|
||||
var child: AnimationNode = bt.get_node(StringName(n_name))
|
||||
var node_info := {
|
||||
"name": n_name,
|
||||
"type": child.get_class(),
|
||||
"position": {"x": bt.get_node_position(StringName(n_name)).x, "y": bt.get_node_position(StringName(n_name)).y},
|
||||
}
|
||||
if child is AnimationNodeAnimation:
|
||||
node_info["animation"] = str((child as AnimationNodeAnimation).animation)
|
||||
nodes_info.append(node_info)
|
||||
|
||||
# Read connections
|
||||
# BlendTree connections are stored as "node_connections" in properties
|
||||
# We can read them from the resource property list
|
||||
for prop in prop_list:
|
||||
var pname: String = prop["name"]
|
||||
if pname.begins_with("nodes/") and pname.ends_with("/node"):
|
||||
continue
|
||||
if pname.begins_with("nodes/") and pname.ends_with("/position"):
|
||||
continue
|
||||
# Connection format: "node_connection/<idx>/<input_node>/<input_port>"
|
||||
# Actually connections are stored differently - let's skip for now
|
||||
|
||||
return {
|
||||
"type": "AnimationNodeBlendTree",
|
||||
"nodes": nodes_info,
|
||||
}
|
||||
|
||||
|
||||
func _add_state_machine_state(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "state_name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var state_name: String = result2[0]
|
||||
|
||||
var tree := _find_animation_tree(node_path)
|
||||
if tree == null:
|
||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
||||
|
||||
var sm_path: String = optional_string(params, "state_machine_path", "")
|
||||
var sm_result := _resolve_state_machine(tree, sm_path)
|
||||
if sm_result[1] != null:
|
||||
return sm_result[1]
|
||||
var sm: AnimationNodeStateMachine = sm_result[0]
|
||||
|
||||
if sm.has_node(StringName(state_name)):
|
||||
return error_invalid_params("State '%s' already exists" % state_name)
|
||||
|
||||
var state_type: String = optional_string(params, "state_type", "animation")
|
||||
var position_x: float = float(params.get("position_x", 0.0))
|
||||
var position_y: float = float(params.get("position_y", 0.0))
|
||||
var position := Vector2(position_x, position_y)
|
||||
|
||||
var node: AnimationNode
|
||||
match state_type:
|
||||
"animation":
|
||||
var anim_node := AnimationNodeAnimation.new()
|
||||
var anim_name: String = optional_string(params, "animation", "")
|
||||
if not anim_name.is_empty():
|
||||
anim_node.animation = StringName(anim_name)
|
||||
node = anim_node
|
||||
"blend_tree":
|
||||
node = AnimationNodeBlendTree.new()
|
||||
"state_machine":
|
||||
node = AnimationNodeStateMachine.new()
|
||||
_:
|
||||
return error_invalid_params("Unknown state_type: '%s'. Use 'animation', 'blend_tree', or 'state_machine'" % state_type)
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Add state machine state")
|
||||
undo_redo.add_do_method(sm, "add_node", StringName(state_name), node, position)
|
||||
undo_redo.add_do_reference(node)
|
||||
undo_redo.add_undo_method(sm, "remove_node", StringName(state_name))
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"state_name": state_name,
|
||||
"state_type": state_type,
|
||||
"position": {"x": position_x, "y": position_y},
|
||||
"added": true,
|
||||
})
|
||||
|
||||
|
||||
func _remove_state_machine_state(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "state_name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var state_name: String = result2[0]
|
||||
|
||||
var tree := _find_animation_tree(node_path)
|
||||
if tree == null:
|
||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
||||
|
||||
var sm_path: String = optional_string(params, "state_machine_path", "")
|
||||
var sm_result := _resolve_state_machine(tree, sm_path)
|
||||
if sm_result[1] != null:
|
||||
return sm_result[1]
|
||||
var sm: AnimationNodeStateMachine = sm_result[0]
|
||||
|
||||
if not sm.has_node(StringName(state_name)):
|
||||
return error_not_found("State '%s'" % state_name)
|
||||
|
||||
var old_node := sm.get_node(StringName(state_name))
|
||||
var old_position := sm.get_node_position(StringName(state_name))
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Remove state machine state")
|
||||
undo_redo.add_do_method(sm, "remove_node", StringName(state_name))
|
||||
undo_redo.add_undo_method(sm, "add_node", StringName(state_name), old_node, old_position)
|
||||
undo_redo.add_undo_reference(old_node)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"state_name": state_name, "removed": true})
|
||||
|
||||
|
||||
func _add_state_machine_transition(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "from_state")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var from_state: String = result2[0]
|
||||
|
||||
var result3 := require_string(params, "to_state")
|
||||
if result3[1] != null:
|
||||
return result3[1]
|
||||
var to_state: String = result3[0]
|
||||
|
||||
var tree := _find_animation_tree(node_path)
|
||||
if tree == null:
|
||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
||||
|
||||
var sm_path: String = optional_string(params, "state_machine_path", "")
|
||||
var sm_result := _resolve_state_machine(tree, sm_path)
|
||||
if sm_result[1] != null:
|
||||
return sm_result[1]
|
||||
var sm: AnimationNodeStateMachine = sm_result[0]
|
||||
|
||||
# Validate states exist (Start and End are special built-in nodes)
|
||||
if from_state != "Start" and from_state != "End" and not sm.has_node(StringName(from_state)):
|
||||
return error_not_found("State '%s'" % from_state)
|
||||
if to_state != "Start" and to_state != "End" and not sm.has_node(StringName(to_state)):
|
||||
return error_not_found("State '%s'" % to_state)
|
||||
|
||||
var transition := AnimationNodeStateMachineTransition.new()
|
||||
|
||||
# switch_mode: AT_END=0, IMMEDIATE=1, SYNC=2
|
||||
var switch_mode_str: String = optional_string(params, "switch_mode", "immediate")
|
||||
match switch_mode_str:
|
||||
"at_end": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END
|
||||
"immediate": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE
|
||||
"sync": transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END # SYNC maps similarly
|
||||
_: transition.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_IMMEDIATE
|
||||
|
||||
# advance_mode: DISABLED=0, ENABLED=1, AUTO=2
|
||||
var advance_mode_str: String = optional_string(params, "advance_mode", "enabled")
|
||||
match advance_mode_str:
|
||||
"disabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_DISABLED
|
||||
"enabled": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED
|
||||
"auto": transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
|
||||
_: transition.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED
|
||||
|
||||
# advance_expression
|
||||
var expression: String = optional_string(params, "advance_expression", "")
|
||||
if not expression.is_empty():
|
||||
transition.advance_expression = expression
|
||||
|
||||
# xfade_time
|
||||
if params.has("xfade_time"):
|
||||
transition.xfade_time = float(params["xfade_time"])
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Add state machine transition")
|
||||
undo_redo.add_do_method(sm, "add_transition", StringName(from_state), StringName(to_state), transition)
|
||||
undo_redo.add_do_reference(transition)
|
||||
undo_redo.add_undo_method(sm, "remove_transition", StringName(from_state), StringName(to_state))
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"from": from_state,
|
||||
"to": to_state,
|
||||
"switch_mode": switch_mode_str,
|
||||
"advance_mode": advance_mode_str,
|
||||
"advance_expression": expression,
|
||||
"added": true,
|
||||
})
|
||||
|
||||
|
||||
func _remove_state_machine_transition(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "from_state")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var from_state: String = result2[0]
|
||||
|
||||
var result3 := require_string(params, "to_state")
|
||||
if result3[1] != null:
|
||||
return result3[1]
|
||||
var to_state: String = result3[0]
|
||||
|
||||
var tree := _find_animation_tree(node_path)
|
||||
if tree == null:
|
||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
||||
|
||||
var sm_path: String = optional_string(params, "state_machine_path", "")
|
||||
var sm_result := _resolve_state_machine(tree, sm_path)
|
||||
if sm_result[1] != null:
|
||||
return sm_result[1]
|
||||
var sm: AnimationNodeStateMachine = sm_result[0]
|
||||
|
||||
# Check if transition exists
|
||||
var found := false
|
||||
for i in sm.get_transition_count():
|
||||
if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state:
|
||||
found = true
|
||||
break
|
||||
|
||||
if not found:
|
||||
return error_not_found("Transition from '%s' to '%s'" % [from_state, to_state])
|
||||
|
||||
var transition: AnimationNodeStateMachineTransition = null
|
||||
for i in sm.get_transition_count():
|
||||
if str(sm.get_transition_from(i)) == from_state and str(sm.get_transition_to(i)) == to_state:
|
||||
transition = sm.get_transition(i)
|
||||
break
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Remove state machine transition")
|
||||
undo_redo.add_do_method(sm, "remove_transition", StringName(from_state), StringName(to_state))
|
||||
undo_redo.add_undo_method(sm, "add_transition", StringName(from_state), StringName(to_state), transition)
|
||||
undo_redo.add_undo_reference(transition)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"from": from_state, "to": to_state, "removed": true})
|
||||
|
||||
|
||||
func _set_blend_tree_node(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "blend_tree_state")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var bt_state: String = result2[0]
|
||||
|
||||
var result3 := require_string(params, "bt_node_name")
|
||||
if result3[1] != null:
|
||||
return result3[1]
|
||||
var bt_node_name: String = result3[0]
|
||||
|
||||
var result4 := require_string(params, "bt_node_type")
|
||||
if result4[1] != null:
|
||||
return result4[1]
|
||||
var bt_node_type: String = result4[0]
|
||||
|
||||
var tree := _find_animation_tree(node_path)
|
||||
if tree == null:
|
||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
||||
|
||||
var sm_path: String = optional_string(params, "state_machine_path", "")
|
||||
var bt_result := _resolve_blend_tree(tree, sm_path, bt_state)
|
||||
if bt_result[1] != null:
|
||||
return bt_result[1]
|
||||
var bt: AnimationNodeBlendTree = bt_result[0]
|
||||
|
||||
var position_x: float = float(params.get("position_x", 0.0))
|
||||
var position_y: float = float(params.get("position_y", 0.0))
|
||||
var position := Vector2(position_x, position_y)
|
||||
|
||||
var had_old_node := bt.has_node(StringName(bt_node_name))
|
||||
var old_node: AnimationNode = bt.get_node(StringName(bt_node_name)) if had_old_node else null
|
||||
var old_position := bt.get_node_position(StringName(bt_node_name)) if had_old_node else Vector2.ZERO
|
||||
|
||||
var node: AnimationNode
|
||||
match bt_node_type:
|
||||
"Animation":
|
||||
var anim_node := AnimationNodeAnimation.new()
|
||||
var anim_name: String = optional_string(params, "animation", "")
|
||||
if not anim_name.is_empty():
|
||||
anim_node.animation = StringName(anim_name)
|
||||
node = anim_node
|
||||
"Add2":
|
||||
node = AnimationNodeAdd2.new()
|
||||
"Blend2":
|
||||
node = AnimationNodeBlend2.new()
|
||||
"Add3":
|
||||
node = AnimationNodeAdd3.new()
|
||||
"Blend3":
|
||||
node = AnimationNodeBlend3.new()
|
||||
"TimeScale":
|
||||
node = AnimationNodeTimeScale.new()
|
||||
"TimeSeek":
|
||||
node = AnimationNodeTimeSeek.new()
|
||||
"Transition":
|
||||
node = AnimationNodeTransition.new()
|
||||
"OneShot":
|
||||
node = AnimationNodeOneShot.new()
|
||||
"Sub2":
|
||||
node = AnimationNodeSub2.new()
|
||||
_:
|
||||
return error_invalid_params("Unknown bt_node_type: '%s'. Use: Animation, Add2, Blend2, Add3, Blend3, TimeScale, TimeSeek, Transition, OneShot, Sub2" % bt_node_type)
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set blend tree node")
|
||||
if had_old_node:
|
||||
undo_redo.add_do_method(bt, "remove_node", StringName(bt_node_name))
|
||||
undo_redo.add_undo_method(bt, "add_node", StringName(bt_node_name), old_node, old_position)
|
||||
undo_redo.add_undo_reference(old_node)
|
||||
undo_redo.add_do_method(bt, "add_node", StringName(bt_node_name), node, position)
|
||||
undo_redo.add_do_reference(node)
|
||||
undo_redo.add_undo_method(bt, "remove_node", StringName(bt_node_name))
|
||||
|
||||
# Connect to another node if specified
|
||||
var connect_to: String = optional_string(params, "connect_to", "")
|
||||
var connect_port: int = optional_int(params, "connect_port", 0)
|
||||
if not connect_to.is_empty():
|
||||
undo_redo.add_do_method(bt, "connect_node", StringName(connect_to), connect_port, StringName(bt_node_name))
|
||||
undo_redo.commit_action()
|
||||
|
||||
var connected_to_value: Variant = null
|
||||
if not connect_to.is_empty():
|
||||
connected_to_value = connect_to
|
||||
return success({
|
||||
"blend_tree_state": bt_state,
|
||||
"bt_node_name": bt_node_name,
|
||||
"bt_node_type": bt_node_type,
|
||||
"position": {"x": position_x, "y": position_y},
|
||||
"connected_to": connected_to_value,
|
||||
"added": true,
|
||||
})
|
||||
|
||||
|
||||
func _set_tree_parameter(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "parameter")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var parameter: String = result2[0]
|
||||
|
||||
var tree := _find_animation_tree(node_path)
|
||||
if tree == null:
|
||||
return error_not_found("AnimationTree at '%s'" % node_path)
|
||||
|
||||
if not params.has("value"):
|
||||
return error_invalid_params("Missing required parameter: value")
|
||||
|
||||
var value = params["value"]
|
||||
|
||||
# Prefix with "parameters/" if not already
|
||||
if not parameter.begins_with("parameters/"):
|
||||
parameter = "parameters/" + parameter
|
||||
|
||||
# Parse string values for common types
|
||||
if value is String:
|
||||
var s: String = value
|
||||
var expr := Expression.new()
|
||||
if expr.parse(s) == OK:
|
||||
var parsed = expr.execute()
|
||||
if parsed != null:
|
||||
value = parsed
|
||||
|
||||
set_property_with_undo(tree, parameter, value, "MCP: Set AnimationTree parameter")
|
||||
|
||||
# Read back to confirm
|
||||
var actual = tree.get(parameter)
|
||||
|
||||
return success({
|
||||
"parameter": parameter,
|
||||
"value": str(actual),
|
||||
"set": true,
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_audio_bus_layout": _get_audio_bus_layout,
|
||||
"add_audio_bus": _add_audio_bus,
|
||||
"set_audio_bus": _set_audio_bus,
|
||||
"add_audio_bus_effect": _add_audio_bus_effect,
|
||||
"add_audio_player": _add_audio_player,
|
||||
"get_audio_info": _get_audio_info,
|
||||
}
|
||||
|
||||
|
||||
func _get_audio_bus_layout(_params: Dictionary) -> Dictionary:
|
||||
var buses: Array[Dictionary] = []
|
||||
for i in range(AudioServer.bus_count):
|
||||
var bus_data := {
|
||||
"index": i,
|
||||
"name": AudioServer.get_bus_name(i),
|
||||
"volume_db": AudioServer.get_bus_volume_db(i),
|
||||
"solo": AudioServer.is_bus_solo(i),
|
||||
"mute": AudioServer.is_bus_mute(i),
|
||||
"bypass_effects": AudioServer.is_bus_bypassing_effects(i),
|
||||
"send": AudioServer.get_bus_send(i),
|
||||
"effects": [],
|
||||
}
|
||||
var effects: Array[Dictionary] = []
|
||||
for j in range(AudioServer.get_bus_effect_count(i)):
|
||||
var effect := AudioServer.get_bus_effect(i, j)
|
||||
var effect_data := {
|
||||
"index": j,
|
||||
"type": effect.get_class(),
|
||||
"enabled": AudioServer.is_bus_effect_enabled(i, j),
|
||||
}
|
||||
# Include effect-specific parameters
|
||||
effect_data["params"] = _get_effect_params(effect)
|
||||
effects.append(effect_data)
|
||||
bus_data["effects"] = effects
|
||||
buses.append(bus_data)
|
||||
return success({"bus_count": AudioServer.bus_count, "buses": buses})
|
||||
|
||||
|
||||
func _get_effect_params(effect: AudioEffect) -> Dictionary:
|
||||
var params := {}
|
||||
if effect is AudioEffectReverb:
|
||||
var rev := effect as AudioEffectReverb
|
||||
params = {"room_size": rev.room_size, "damping": rev.damping, "wet": rev.wet, "dry": rev.dry, "spread": rev.spread}
|
||||
elif effect is AudioEffectDelay:
|
||||
var d := effect as AudioEffectDelay
|
||||
params = {"tap1_active": d.tap1_active, "tap1_delay_ms": d.tap1_delay_ms, "tap1_level_db": d.tap1_level_db, "tap2_active": d.tap2_active, "tap2_delay_ms": d.tap2_delay_ms, "tap2_level_db": d.tap2_level_db}
|
||||
elif effect is AudioEffectCompressor:
|
||||
var c := effect as AudioEffectCompressor
|
||||
params = {"threshold": c.threshold, "ratio": c.ratio, "attack_us": c.attack_us, "release_ms": c.release_ms, "gain": c.gain, "mix": c.mix, "sidechain": c.sidechain}
|
||||
elif effect is AudioEffectLimiter:
|
||||
var l := effect as AudioEffectLimiter
|
||||
params = {"ceiling_db": l.ceiling_db, "threshold_db": l.threshold_db, "soft_clip_db": l.soft_clip_db, "soft_clip_ratio": l.soft_clip_ratio}
|
||||
elif effect is AudioEffectDistortion:
|
||||
var dist := effect as AudioEffectDistortion
|
||||
params = {"mode": dist.mode, "pre_gain": dist.pre_gain, "post_gain": dist.post_gain, "keep_hf_hz": dist.keep_hf_hz, "drive": dist.drive}
|
||||
elif effect is AudioEffectChorus:
|
||||
var ch := effect as AudioEffectChorus
|
||||
params = {"voice_count": ch.voice_count, "dry": ch.dry, "wet": ch.wet}
|
||||
elif effect is AudioEffectPhaser:
|
||||
var ph := effect as AudioEffectPhaser
|
||||
params = {"range_min_hz": ph.range_min_hz, "range_max_hz": ph.range_max_hz, "rate_hz": ph.rate_hz, "feedback": ph.feedback, "depth": ph.depth}
|
||||
elif effect is AudioEffectFilter:
|
||||
# Covers LowPassFilter, HighPassFilter, BandPassFilter, etc.
|
||||
var f := effect as AudioEffectFilter
|
||||
params = {"cutoff_hz": f.cutoff_hz, "resonance": f.resonance, "gain": f.gain, "db": f.db}
|
||||
elif effect is AudioEffectAmplify:
|
||||
var a := effect as AudioEffectAmplify
|
||||
params = {"volume_db": a.volume_db}
|
||||
return params
|
||||
|
||||
|
||||
func _add_audio_bus(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "name")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var bus_name: String = result[0]
|
||||
|
||||
# Check if bus name already exists
|
||||
for i in range(AudioServer.bus_count):
|
||||
if AudioServer.get_bus_name(i) == bus_name:
|
||||
return error_invalid_params("Audio bus '%s' already exists at index %d" % [bus_name, i])
|
||||
|
||||
var at_position: int = optional_int(params, "at_position", -1)
|
||||
AudioServer.add_bus(at_position)
|
||||
|
||||
var idx: int = AudioServer.bus_count - 1 if at_position < 0 else at_position
|
||||
AudioServer.set_bus_name(idx, bus_name)
|
||||
|
||||
if params.has("volume_db"):
|
||||
AudioServer.set_bus_volume_db(idx, float(params["volume_db"]))
|
||||
|
||||
var send: String = optional_string(params, "send", "")
|
||||
if not send.is_empty():
|
||||
AudioServer.set_bus_send(idx, send)
|
||||
|
||||
if params.has("solo"):
|
||||
AudioServer.set_bus_solo(idx, bool(params["solo"]))
|
||||
|
||||
if params.has("mute"):
|
||||
AudioServer.set_bus_mute(idx, bool(params["mute"]))
|
||||
|
||||
return success({"name": bus_name, "index": idx, "bus_count": AudioServer.bus_count})
|
||||
|
||||
|
||||
func _set_audio_bus(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "name")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var bus_name: String = result[0]
|
||||
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx < 0:
|
||||
return error_not_found("Audio bus '%s'" % bus_name)
|
||||
|
||||
var changes := 0
|
||||
|
||||
if params.has("volume_db"):
|
||||
AudioServer.set_bus_volume_db(idx, float(params["volume_db"]))
|
||||
changes += 1
|
||||
|
||||
if params.has("solo"):
|
||||
AudioServer.set_bus_solo(idx, bool(params["solo"]))
|
||||
changes += 1
|
||||
|
||||
if params.has("mute"):
|
||||
AudioServer.set_bus_mute(idx, bool(params["mute"]))
|
||||
changes += 1
|
||||
|
||||
if params.has("bypass_effects"):
|
||||
AudioServer.set_bus_bypass_effects(idx, bool(params["bypass_effects"]))
|
||||
changes += 1
|
||||
|
||||
var send: String = optional_string(params, "send", "")
|
||||
if not send.is_empty():
|
||||
AudioServer.set_bus_send(idx, send)
|
||||
changes += 1
|
||||
|
||||
if params.has("rename"):
|
||||
var new_name: String = str(params["rename"])
|
||||
AudioServer.set_bus_name(idx, new_name)
|
||||
bus_name = new_name
|
||||
changes += 1
|
||||
|
||||
return success({"name": bus_name, "index": idx, "changes": changes})
|
||||
|
||||
|
||||
func _add_audio_bus_effect(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "bus")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var bus_name: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "effect_type")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var effect_type: String = result2[0]
|
||||
|
||||
var bus_idx := AudioServer.get_bus_index(bus_name)
|
||||
if bus_idx < 0:
|
||||
return error_not_found("Audio bus '%s'" % bus_name)
|
||||
|
||||
var effect: AudioEffect = null
|
||||
var effect_params: Dictionary = params.get("params", {}) if params.has("params") else {}
|
||||
|
||||
match effect_type.to_lower():
|
||||
"reverb":
|
||||
var e := AudioEffectReverb.new()
|
||||
if effect_params.has("room_size"):
|
||||
e.room_size = float(effect_params["room_size"])
|
||||
if effect_params.has("damping"):
|
||||
e.damping = float(effect_params["damping"])
|
||||
if effect_params.has("wet"):
|
||||
e.wet = float(effect_params["wet"])
|
||||
if effect_params.has("dry"):
|
||||
e.dry = float(effect_params["dry"])
|
||||
if effect_params.has("spread"):
|
||||
e.spread = float(effect_params["spread"])
|
||||
effect = e
|
||||
"chorus":
|
||||
var e := AudioEffectChorus.new()
|
||||
if effect_params.has("voice_count"):
|
||||
e.voice_count = int(effect_params["voice_count"])
|
||||
if effect_params.has("dry"):
|
||||
e.dry = float(effect_params["dry"])
|
||||
if effect_params.has("wet"):
|
||||
e.wet = float(effect_params["wet"])
|
||||
effect = e
|
||||
"delay":
|
||||
var e := AudioEffectDelay.new()
|
||||
if effect_params.has("tap1_active"):
|
||||
e.tap1_active = bool(effect_params["tap1_active"])
|
||||
if effect_params.has("tap1_delay_ms"):
|
||||
e.tap1_delay_ms = float(effect_params["tap1_delay_ms"])
|
||||
if effect_params.has("tap1_level_db"):
|
||||
e.tap1_level_db = float(effect_params["tap1_level_db"])
|
||||
if effect_params.has("tap2_active"):
|
||||
e.tap2_active = bool(effect_params["tap2_active"])
|
||||
if effect_params.has("tap2_delay_ms"):
|
||||
e.tap2_delay_ms = float(effect_params["tap2_delay_ms"])
|
||||
if effect_params.has("tap2_level_db"):
|
||||
e.tap2_level_db = float(effect_params["tap2_level_db"])
|
||||
effect = e
|
||||
"compressor":
|
||||
var e := AudioEffectCompressor.new()
|
||||
if effect_params.has("threshold"):
|
||||
e.threshold = float(effect_params["threshold"])
|
||||
if effect_params.has("ratio"):
|
||||
e.ratio = float(effect_params["ratio"])
|
||||
if effect_params.has("attack_us"):
|
||||
e.attack_us = float(effect_params["attack_us"])
|
||||
if effect_params.has("release_ms"):
|
||||
e.release_ms = float(effect_params["release_ms"])
|
||||
if effect_params.has("gain"):
|
||||
e.gain = float(effect_params["gain"])
|
||||
if effect_params.has("mix"):
|
||||
e.mix = float(effect_params["mix"])
|
||||
effect = e
|
||||
"limiter":
|
||||
var e := AudioEffectLimiter.new()
|
||||
if effect_params.has("ceiling_db"):
|
||||
e.ceiling_db = float(effect_params["ceiling_db"])
|
||||
if effect_params.has("threshold_db"):
|
||||
e.threshold_db = float(effect_params["threshold_db"])
|
||||
if effect_params.has("soft_clip_db"):
|
||||
e.soft_clip_db = float(effect_params["soft_clip_db"])
|
||||
if effect_params.has("soft_clip_ratio"):
|
||||
e.soft_clip_ratio = float(effect_params["soft_clip_ratio"])
|
||||
effect = e
|
||||
"phaser":
|
||||
var e := AudioEffectPhaser.new()
|
||||
if effect_params.has("range_min_hz"):
|
||||
e.range_min_hz = float(effect_params["range_min_hz"])
|
||||
if effect_params.has("range_max_hz"):
|
||||
e.range_max_hz = float(effect_params["range_max_hz"])
|
||||
if effect_params.has("rate_hz"):
|
||||
e.rate_hz = float(effect_params["rate_hz"])
|
||||
if effect_params.has("feedback"):
|
||||
e.feedback = float(effect_params["feedback"])
|
||||
if effect_params.has("depth"):
|
||||
e.depth = float(effect_params["depth"])
|
||||
effect = e
|
||||
"distortion":
|
||||
var e := AudioEffectDistortion.new()
|
||||
if effect_params.has("mode"):
|
||||
e.mode = int(effect_params["mode"]) as AudioEffectDistortion.Mode
|
||||
if effect_params.has("pre_gain"):
|
||||
e.pre_gain = float(effect_params["pre_gain"])
|
||||
if effect_params.has("post_gain"):
|
||||
e.post_gain = float(effect_params["post_gain"])
|
||||
if effect_params.has("keep_hf_hz"):
|
||||
e.keep_hf_hz = float(effect_params["keep_hf_hz"])
|
||||
if effect_params.has("drive"):
|
||||
e.drive = float(effect_params["drive"])
|
||||
effect = e
|
||||
"lowpassfilter", "lowpass":
|
||||
var e := AudioEffectLowPassFilter.new()
|
||||
if effect_params.has("cutoff_hz"):
|
||||
e.cutoff_hz = float(effect_params["cutoff_hz"])
|
||||
if effect_params.has("resonance"):
|
||||
e.resonance = float(effect_params["resonance"])
|
||||
effect = e
|
||||
"highpassfilter", "highpass":
|
||||
var e := AudioEffectHighPassFilter.new()
|
||||
if effect_params.has("cutoff_hz"):
|
||||
e.cutoff_hz = float(effect_params["cutoff_hz"])
|
||||
if effect_params.has("resonance"):
|
||||
e.resonance = float(effect_params["resonance"])
|
||||
effect = e
|
||||
"bandpassfilter", "bandpass":
|
||||
var e := AudioEffectBandPassFilter.new()
|
||||
if effect_params.has("cutoff_hz"):
|
||||
e.cutoff_hz = float(effect_params["cutoff_hz"])
|
||||
if effect_params.has("resonance"):
|
||||
e.resonance = float(effect_params["resonance"])
|
||||
effect = e
|
||||
"amplify":
|
||||
var e := AudioEffectAmplify.new()
|
||||
if effect_params.has("volume_db"):
|
||||
e.volume_db = float(effect_params["volume_db"])
|
||||
effect = e
|
||||
"eq":
|
||||
var e := AudioEffectEQ.new()
|
||||
effect = e
|
||||
_:
|
||||
return error_invalid_params("Unknown effect type: '%s'. Valid types: reverb, chorus, delay, compressor, limiter, phaser, distortion, lowpassfilter, highpassfilter, bandpassfilter, amplify, eq" % effect_type)
|
||||
|
||||
var at_position: int = optional_int(params, "at_position", -1)
|
||||
AudioServer.add_bus_effect(bus_idx, effect, at_position)
|
||||
|
||||
var effect_idx: int = AudioServer.get_bus_effect_count(bus_idx) - 1 if at_position < 0 else at_position
|
||||
return success({"bus": bus_name, "bus_index": bus_idx, "effect_type": effect.get_class(), "effect_index": effect_idx})
|
||||
|
||||
|
||||
func _add_audio_player(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var player_name: String = result2[0]
|
||||
|
||||
var player_type: String = optional_string(params, "type", "AudioStreamPlayer")
|
||||
var valid_types := ["AudioStreamPlayer", "AudioStreamPlayer2D", "AudioStreamPlayer3D"]
|
||||
if player_type not in valid_types:
|
||||
return error_invalid_params("Invalid player type '%s'. Valid: %s" % [player_type, ", ".join(valid_types)])
|
||||
|
||||
var parent := find_node_by_path(node_path)
|
||||
if parent == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var player: Node = null
|
||||
match player_type:
|
||||
"AudioStreamPlayer":
|
||||
player = AudioStreamPlayer.new()
|
||||
"AudioStreamPlayer2D":
|
||||
player = AudioStreamPlayer2D.new()
|
||||
"AudioStreamPlayer3D":
|
||||
player = AudioStreamPlayer3D.new()
|
||||
|
||||
player.name = player_name
|
||||
|
||||
# Set stream if provided
|
||||
var stream_path: String = optional_string(params, "stream", "")
|
||||
if not stream_path.is_empty():
|
||||
if ResourceLoader.exists(stream_path):
|
||||
var stream = ResourceLoader.load(stream_path)
|
||||
if stream is AudioStream:
|
||||
player.set("stream", stream)
|
||||
else:
|
||||
player.queue_free()
|
||||
return error_invalid_params("Resource at '%s' is not an AudioStream" % stream_path)
|
||||
else:
|
||||
player.queue_free()
|
||||
return error_not_found("Audio stream at '%s'" % stream_path)
|
||||
|
||||
# Common properties
|
||||
if params.has("volume_db"):
|
||||
player.set("volume_db", float(params["volume_db"]))
|
||||
|
||||
var bus: String = optional_string(params, "bus", "")
|
||||
if not bus.is_empty():
|
||||
player.set("bus", bus)
|
||||
|
||||
if params.has("autoplay"):
|
||||
player.set("autoplay", bool(params["autoplay"]))
|
||||
|
||||
# 2D-specific properties
|
||||
if player is AudioStreamPlayer2D:
|
||||
if params.has("max_distance"):
|
||||
(player as AudioStreamPlayer2D).max_distance = float(params["max_distance"])
|
||||
if params.has("attenuation"):
|
||||
(player as AudioStreamPlayer2D).attenuation = float(params["attenuation"])
|
||||
|
||||
# 3D-specific properties
|
||||
if player is AudioStreamPlayer3D:
|
||||
if params.has("max_distance"):
|
||||
(player as AudioStreamPlayer3D).max_distance = float(params["max_distance"])
|
||||
if params.has("attenuation_model"):
|
||||
(player as AudioStreamPlayer3D).attenuation_model = int(params["attenuation_model"]) as AudioStreamPlayer3D.AttenuationModel
|
||||
if params.has("unit_size"):
|
||||
(player as AudioStreamPlayer3D).unit_size = float(params["unit_size"])
|
||||
|
||||
add_child_with_undo(parent, player, root, "MCP: Add audio player")
|
||||
|
||||
return success({
|
||||
"name": player_name,
|
||||
"type": player_type,
|
||||
"parent": node_path,
|
||||
"stream": stream_path,
|
||||
"bus": player.get("bus"),
|
||||
"volume_db": player.get("volume_db"),
|
||||
"autoplay": player.get("autoplay"),
|
||||
})
|
||||
|
||||
|
||||
func _get_audio_info(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
|
||||
var players: Array[Dictionary] = []
|
||||
_collect_audio_players(node, players)
|
||||
|
||||
return success({"node_path": node_path, "audio_player_count": players.size(), "players": players})
|
||||
|
||||
|
||||
func _collect_audio_players(node: Node, result: Array[Dictionary]) -> void:
|
||||
if node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D:
|
||||
var info := {
|
||||
"name": node.name,
|
||||
"path": str(get_edited_root().get_path_to(node)),
|
||||
"type": node.get_class(),
|
||||
"volume_db": node.get("volume_db"),
|
||||
"bus": node.get("bus"),
|
||||
"autoplay": node.get("autoplay"),
|
||||
"playing": node.get("playing"),
|
||||
"stream": "",
|
||||
}
|
||||
var stream = node.get("stream")
|
||||
if stream != null and stream is AudioStream:
|
||||
info["stream"] = stream.resource_path
|
||||
|
||||
if node is AudioStreamPlayer2D:
|
||||
info["max_distance"] = (node as AudioStreamPlayer2D).max_distance
|
||||
info["attenuation"] = (node as AudioStreamPlayer2D).attenuation
|
||||
elif node is AudioStreamPlayer3D:
|
||||
info["max_distance"] = (node as AudioStreamPlayer3D).max_distance
|
||||
info["attenuation_model"] = (node as AudioStreamPlayer3D).attenuation_model
|
||||
info["unit_size"] = (node as AudioStreamPlayer3D).unit_size
|
||||
|
||||
result.append(info)
|
||||
|
||||
for child in node.get_children():
|
||||
_collect_audio_players(child, result)
|
||||
@@ -0,0 +1,255 @@
|
||||
@tool
|
||||
extends Node
|
||||
|
||||
var editor_plugin: EditorPlugin
|
||||
|
||||
|
||||
## Override in subclasses: return {"method_name": Callable}
|
||||
func get_commands() -> Dictionary:
|
||||
return {}
|
||||
|
||||
|
||||
## Helper: return a success result
|
||||
func success(data: Dictionary = {}) -> Dictionary:
|
||||
return {"result": data}
|
||||
|
||||
|
||||
## Helper: return an error
|
||||
func error(code: int, message: String, data: Dictionary = {}) -> Dictionary:
|
||||
var err := {"code": code, "message": message}
|
||||
if not data.is_empty():
|
||||
err["data"] = data
|
||||
return {"error": err}
|
||||
|
||||
|
||||
## Error codes
|
||||
func error_not_found(what: String, suggestion: String = "") -> Dictionary:
|
||||
var data := {}
|
||||
if suggestion:
|
||||
data["suggestion"] = suggestion
|
||||
return error(-32001, "%s not found" % what, data)
|
||||
|
||||
|
||||
func error_invalid_params(message: String) -> Dictionary:
|
||||
return error(-32602, message)
|
||||
|
||||
|
||||
func error_no_scene() -> Dictionary:
|
||||
return error(-32000, "No scene is currently open", {"suggestion": "Use open_scene to open a scene first"})
|
||||
|
||||
|
||||
func error_internal(message: String) -> Dictionary:
|
||||
return error(-32603, "Internal error: %s" % message)
|
||||
|
||||
|
||||
func error_conflict(message: String, data: Dictionary = {}) -> Dictionary:
|
||||
return error(-32009, message, data)
|
||||
|
||||
|
||||
## Get required string param
|
||||
func require_string(params: Dictionary, key: String) -> Array:
|
||||
if not params.has(key) or not params[key] is String or (params[key] as String).is_empty():
|
||||
return [null, error_invalid_params("Missing required parameter: %s" % key)]
|
||||
return [params[key] as String, null]
|
||||
|
||||
|
||||
## Get optional string param with default
|
||||
func optional_string(params: Dictionary, key: String, default: String = "") -> String:
|
||||
if params.has(key) and params[key] is String:
|
||||
return params[key] as String
|
||||
return default
|
||||
|
||||
|
||||
## Get optional bool param with default
|
||||
func optional_bool(params: Dictionary, key: String, default: bool = false) -> bool:
|
||||
if params.has(key) and params[key] is bool:
|
||||
return params[key] as bool
|
||||
return default
|
||||
|
||||
|
||||
## Get optional int param with default
|
||||
func optional_int(params: Dictionary, key: String, default: int = 0) -> int:
|
||||
if params.has(key):
|
||||
return int(params[key])
|
||||
return default
|
||||
|
||||
|
||||
## Get the game process's user data directory.
|
||||
## OS.get_user_data_dir() is cached at editor startup and won't reflect
|
||||
## project name changes made to project.godot while the editor is running.
|
||||
## The game process reads the name from disk, so we must do the same.
|
||||
func get_game_user_dir() -> String:
|
||||
var cached_dir := OS.get_user_data_dir()
|
||||
var cfg := ConfigFile.new()
|
||||
var err := cfg.load(ProjectSettings.globalize_path("res://project.godot"))
|
||||
if err != OK:
|
||||
return cached_dir
|
||||
# When use_custom_user_dir=true, editor and game share the same dir
|
||||
# (OS.get_user_data_dir() already resolves to the custom path).
|
||||
if cfg.get_value("application", "config/use_custom_user_dir", false):
|
||||
return cached_dir
|
||||
var disk_name = cfg.get_value("application", "config/name", "")
|
||||
if typeof(disk_name) != TYPE_STRING or (disk_name as String).is_empty():
|
||||
return cached_dir
|
||||
# Sanitize exactly like Godot does when computing the default user dir
|
||||
# (core/config/project_settings.cpp ProjectSettings::_init).
|
||||
var sanitized := (disk_name as String).xml_unescape().validate_filename().replace(".", "_")
|
||||
if sanitized.is_empty():
|
||||
return cached_dir
|
||||
var base_dir := cached_dir.get_base_dir()
|
||||
var game_dir := base_dir.path_join(sanitized)
|
||||
# Ensure the directory exists (game may not have created it yet)
|
||||
if not DirAccess.dir_exists_absolute(game_dir):
|
||||
DirAccess.make_dir_recursive_absolute(game_dir)
|
||||
return game_dir
|
||||
|
||||
|
||||
## Get EditorInterface
|
||||
func get_editor() -> EditorInterface:
|
||||
return editor_plugin.get_editor_interface()
|
||||
|
||||
|
||||
## Get the edited scene root
|
||||
func get_edited_root() -> Node:
|
||||
return EditorInterface.get_edited_scene_root()
|
||||
|
||||
|
||||
## Get UndoRedo
|
||||
func get_undo_redo() -> EditorUndoRedoManager:
|
||||
return editor_plugin.get_undo_redo()
|
||||
|
||||
|
||||
func normalize_project_path(path: String) -> String:
|
||||
if path.is_empty():
|
||||
return ""
|
||||
if path.begins_with("res://") or path.begins_with("user://"):
|
||||
return path.simplify_path()
|
||||
return ProjectSettings.localize_path(path).simplify_path()
|
||||
|
||||
|
||||
func is_scene_resource_path(path: String) -> bool:
|
||||
var ext := path.get_extension().to_lower()
|
||||
return ext == "tscn" or ext == "scn"
|
||||
|
||||
|
||||
func get_open_scene_paths() -> Array[String]:
|
||||
var paths: Array[String] = []
|
||||
var open_scenes: PackedStringArray = EditorInterface.get_open_scenes()
|
||||
for scene_path: String in open_scenes:
|
||||
var normalized := normalize_project_path(scene_path)
|
||||
if not normalized.is_empty() and normalized not in paths:
|
||||
paths.append(normalized)
|
||||
|
||||
var root := get_edited_root()
|
||||
if root != null and not root.scene_file_path.is_empty():
|
||||
var active_path := normalize_project_path(root.scene_file_path)
|
||||
if active_path not in paths:
|
||||
paths.append(active_path)
|
||||
return paths
|
||||
|
||||
|
||||
func is_scene_path_open(path: String) -> bool:
|
||||
var normalized := normalize_project_path(path)
|
||||
if normalized.is_empty():
|
||||
return false
|
||||
return normalized in get_open_scene_paths()
|
||||
|
||||
|
||||
func is_active_scene_path(path: String) -> bool:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return false
|
||||
return normalize_project_path(root.scene_file_path) == normalize_project_path(path)
|
||||
|
||||
|
||||
func guard_offline_scene_save(path: String) -> Dictionary:
|
||||
if is_scene_resource_path(path) and is_scene_path_open(path):
|
||||
return error_conflict(
|
||||
"Refusing to save open scene '%s' outside the Godot editor state" % normalize_project_path(path),
|
||||
{
|
||||
"path": normalize_project_path(path),
|
||||
"open_scenes": get_open_scene_paths(),
|
||||
"suggestion": "Use live editor changes plus save_scene, or close the scene before offline edits.",
|
||||
}
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
func is_shader_resource_path(path: String) -> bool:
|
||||
var ext := path.get_extension().to_lower()
|
||||
return ext == "gdshader" or ext == "gdshaderinc" or ext == "shader"
|
||||
|
||||
|
||||
func is_text_resource_open_in_script_editor(path: String) -> bool:
|
||||
var target := normalize_project_path(path)
|
||||
if target.is_empty():
|
||||
return false
|
||||
if is_shader_resource_path(target) and ResourceLoader.has_cached(target):
|
||||
return true
|
||||
var script_editor := EditorInterface.get_script_editor()
|
||||
if script_editor == null:
|
||||
return false
|
||||
for open_resource in script_editor.get_open_scripts():
|
||||
if open_resource is Resource:
|
||||
var resource_path := normalize_project_path((open_resource as Resource).resource_path)
|
||||
if resource_path == target:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func guard_text_resource_write(path: String, force: bool) -> Dictionary:
|
||||
if not force and is_text_resource_open_in_script_editor(path):
|
||||
return error_conflict(
|
||||
"Refusing to write open text resource '%s' outside the script editor state" % normalize_project_path(path),
|
||||
{
|
||||
"path": normalize_project_path(path),
|
||||
"suggestion": "Close the file in Godot's script editor or pass force=true to overwrite it deliberately.",
|
||||
}
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
func mark_current_scene_unsaved() -> void:
|
||||
if EditorInterface.has_method("mark_scene_as_unsaved"):
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
|
||||
|
||||
func add_child_with_undo(parent: Node, child: Node, root: Node, action_name: String) -> void:
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action(action_name)
|
||||
undo_redo.add_do_method(parent, "add_child", child)
|
||||
undo_redo.add_do_method(child, "set_owner", root)
|
||||
undo_redo.add_do_reference(child)
|
||||
undo_redo.add_undo_method(parent, "remove_child", child)
|
||||
undo_redo.commit_action()
|
||||
|
||||
|
||||
func set_property_with_undo(target: Object, property: String, new_value: Variant, action_name: String) -> void:
|
||||
var old_value: Variant = target.get(property)
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action(action_name)
|
||||
undo_redo.add_do_property(target, property, new_value)
|
||||
if new_value is Resource:
|
||||
undo_redo.add_do_reference(new_value)
|
||||
undo_redo.add_undo_property(target, property, old_value)
|
||||
if old_value is Resource:
|
||||
undo_redo.add_undo_reference(old_value)
|
||||
undo_redo.commit_action()
|
||||
|
||||
|
||||
## Find node by path in edited scene
|
||||
func find_node_by_path(node_path: String) -> Node:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return null
|
||||
if node_path == "." or node_path == root.name:
|
||||
return root
|
||||
# Try relative from root
|
||||
if root.has_node(node_path):
|
||||
return root.get_node(node_path)
|
||||
# Try with root name prefix stripped
|
||||
if node_path.begins_with(root.name + "/"):
|
||||
var rel := node_path.substr(root.name.length() + 1)
|
||||
if root.has_node(rel):
|
||||
return root.get_node(rel)
|
||||
return null
|
||||
@@ -0,0 +1,436 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"find_nodes_by_type": _find_nodes_by_type,
|
||||
"find_signal_connections": _find_signal_connections,
|
||||
"batch_set_property": _batch_set_property,
|
||||
"batch_add_nodes": _batch_add_nodes,
|
||||
"find_node_references": _find_node_references,
|
||||
"get_scene_dependencies": _get_scene_dependencies,
|
||||
"cross_scene_set_property": _cross_scene_set_property,
|
||||
}
|
||||
|
||||
|
||||
func _find_nodes_by_type(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "type")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var type_name: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var recursive: bool = optional_bool(params, "recursive", true)
|
||||
var matches: Array = []
|
||||
_search_by_type(root, type_name, recursive, matches)
|
||||
|
||||
return success({"type": type_name, "matches": matches, "count": matches.size()})
|
||||
|
||||
|
||||
func _search_by_type(node: Node, type_name: String, recursive: bool, matches: Array) -> void:
|
||||
if node.is_class(type_name) or node.get_class() == type_name:
|
||||
var root := get_edited_root()
|
||||
matches.append({
|
||||
"name": node.name,
|
||||
"path": str(root.get_path_to(node)),
|
||||
"type": node.get_class(),
|
||||
})
|
||||
if recursive:
|
||||
for child in node.get_children():
|
||||
_search_by_type(child, type_name, recursive, matches)
|
||||
|
||||
|
||||
func _find_signal_connections(params: Dictionary) -> Dictionary:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var signal_filter: String = optional_string(params, "signal_name", "")
|
||||
var node_filter: String = optional_string(params, "node_path", "")
|
||||
|
||||
var connections: Array = []
|
||||
_collect_signals(root, root, signal_filter, node_filter, connections)
|
||||
|
||||
return success({"connections": connections, "count": connections.size()})
|
||||
|
||||
|
||||
func _collect_signals(node: Node, root: Node, signal_filter: String, node_filter: String, connections: Array) -> void:
|
||||
var node_path := str(root.get_path_to(node))
|
||||
|
||||
if node_filter.is_empty() or node_path.contains(node_filter):
|
||||
for sig_info in node.get_signal_list():
|
||||
var sig_name: String = sig_info["name"]
|
||||
if not signal_filter.is_empty() and not sig_name.contains(signal_filter):
|
||||
continue
|
||||
for conn in node.get_signal_connection_list(sig_name):
|
||||
connections.append({
|
||||
"source": node_path,
|
||||
"signal": sig_name,
|
||||
"target": str(root.get_path_to(conn["callable"].get_object())),
|
||||
"method": conn["callable"].get_method(),
|
||||
})
|
||||
|
||||
for child in node.get_children():
|
||||
_collect_signals(child, root, signal_filter, node_filter, connections)
|
||||
|
||||
|
||||
func _batch_set_property(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "type")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var type_name: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "property")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var property: String = result2[0]
|
||||
|
||||
if not params.has("value"):
|
||||
return error_invalid_params("Missing required parameter: value")
|
||||
var value = params["value"]
|
||||
|
||||
# Parse value string
|
||||
if value is String:
|
||||
var s: String = value
|
||||
var expr := Expression.new()
|
||||
if expr.parse(s) == OK:
|
||||
var parsed = expr.execute()
|
||||
if parsed != null:
|
||||
value = parsed
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var affected: Array = []
|
||||
var changes: Array = []
|
||||
_batch_collect_property_changes(root, root, type_name, property, value, affected, changes)
|
||||
if not changes.is_empty():
|
||||
_apply_property_changes_with_undo(changes, property, "MCP: Batch set %s" % property)
|
||||
|
||||
return success({"property": property, "affected": affected, "count": affected.size()})
|
||||
|
||||
|
||||
func _batch_collect_property_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void:
|
||||
if node.is_class(type_name) or node.get_class() == type_name:
|
||||
if property in node:
|
||||
affected.append(str(root.get_path_to(node)))
|
||||
changes.append({
|
||||
"node": node,
|
||||
"old_value": node.get(property),
|
||||
"new_value": value,
|
||||
})
|
||||
for child in node.get_children():
|
||||
_batch_collect_property_changes(child, root, type_name, property, value, affected, changes)
|
||||
|
||||
|
||||
func _batch_add_nodes(params: Dictionary) -> Dictionary:
|
||||
if not params.has("nodes") or not params["nodes"] is Array:
|
||||
return error_invalid_params("Missing required parameter: nodes (Array)")
|
||||
|
||||
var nodes_data: Array = params["nodes"]
|
||||
if nodes_data.is_empty():
|
||||
return error_invalid_params("nodes array is empty")
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var created: Array = []
|
||||
var errors: Array = []
|
||||
|
||||
for i: int in nodes_data.size():
|
||||
var entry: Dictionary = nodes_data[i]
|
||||
|
||||
if not entry.has("type") or not entry["type"] is String:
|
||||
errors.append({"index": i, "error": "Missing or invalid 'type'"})
|
||||
continue
|
||||
|
||||
var type: String = entry["type"]
|
||||
if not ClassDB.class_exists(type):
|
||||
errors.append({"index": i, "error": "Unknown node type: %s" % type})
|
||||
continue
|
||||
|
||||
var parent_path: String = entry.get("parent_path", ".") if entry.has("parent_path") and entry["parent_path"] is String else "."
|
||||
var node_name: String = entry.get("name", "") if entry.has("name") and entry["name"] is String else ""
|
||||
var properties: Dictionary = entry.get("properties", {}) if entry.has("properties") and entry["properties"] is Dictionary else {}
|
||||
|
||||
var parent := find_node_by_path(parent_path)
|
||||
if parent == null:
|
||||
errors.append({"index": i, "error": "Parent node '%s' not found" % parent_path})
|
||||
continue
|
||||
|
||||
var node: Node = ClassDB.instantiate(type)
|
||||
if not node_name.is_empty():
|
||||
node.name = node_name
|
||||
|
||||
for prop_name: String in properties:
|
||||
var prop_exists := false
|
||||
for prop in node.get_property_list():
|
||||
if prop["name"] == prop_name:
|
||||
prop_exists = true
|
||||
break
|
||||
if prop_exists:
|
||||
var current: Variant = node.get(prop_name)
|
||||
var target_type := typeof(current)
|
||||
node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type))
|
||||
|
||||
add_child_with_undo(parent, node, root, "MCP: Batch add %s" % type)
|
||||
|
||||
created.append({
|
||||
"index": i,
|
||||
"type": type,
|
||||
"name": str(node.name),
|
||||
"parent": parent_path,
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
})
|
||||
|
||||
var result := {"created": created, "count": created.size()}
|
||||
if not errors.is_empty():
|
||||
result["errors"] = errors
|
||||
return success(result)
|
||||
|
||||
|
||||
func _find_node_references(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "pattern")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var pattern: String = result[0]
|
||||
|
||||
# Search through all .tscn and .gd files for references
|
||||
var matches: Array = []
|
||||
_search_files_for_pattern("res://", pattern, matches, 100)
|
||||
|
||||
return success({"pattern": pattern, "matches": matches, "count": matches.size()})
|
||||
|
||||
|
||||
func _search_files_for_pattern(path: String, pattern: String, matches: Array, max_results: int) -> void:
|
||||
if matches.size() >= max_results:
|
||||
return
|
||||
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
|
||||
while not file_name.is_empty() and matches.size() < max_results:
|
||||
if file_name.begins_with("."):
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(file_name)
|
||||
|
||||
if dir.current_is_dir():
|
||||
_search_files_for_pattern(full_path, pattern, matches, max_results)
|
||||
elif file_name.get_extension() in ["tscn", "gd", "tres", "gdshader"]:
|
||||
var file := FileAccess.open(full_path, FileAccess.READ)
|
||||
if file:
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
if content.contains(pattern):
|
||||
# Find line numbers
|
||||
var lines := content.split("\n")
|
||||
var line_matches: Array = []
|
||||
for i in lines.size():
|
||||
if lines[i].contains(pattern):
|
||||
line_matches.append(i + 1)
|
||||
if line_matches.size() >= 5:
|
||||
break
|
||||
matches.append({
|
||||
"file": full_path,
|
||||
"lines": line_matches,
|
||||
})
|
||||
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func _cross_scene_set_property(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "type")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var type_name: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "property")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var property: String = result2[0]
|
||||
|
||||
if not params.has("value"):
|
||||
return error_invalid_params("Missing required parameter: value")
|
||||
var value = params["value"]
|
||||
|
||||
# Parse value string
|
||||
if value is String:
|
||||
var expr := Expression.new()
|
||||
if expr.parse(value) == OK:
|
||||
var parsed = expr.execute()
|
||||
if parsed != null:
|
||||
value = parsed
|
||||
|
||||
var path_filter: String = optional_string(params, "path_filter", "res://")
|
||||
var exclude_addons: bool = optional_bool(params, "exclude_addons", true)
|
||||
var force: bool = optional_bool(params, "force", false)
|
||||
var dry_run: bool = optional_bool(params, "dry_run", not force)
|
||||
if not dry_run and not force:
|
||||
return error_invalid_params("cross_scene_set_property requires force=true when dry_run=false")
|
||||
|
||||
var scenes_affected: Array = []
|
||||
var skipped_open_scenes: Array = []
|
||||
var total_nodes: int = 0
|
||||
var scene_files: Array = []
|
||||
_collect_scene_files(path_filter, scene_files, exclude_addons)
|
||||
|
||||
for scene_path: String in scene_files:
|
||||
var normalized_scene_path := normalize_project_path(scene_path)
|
||||
|
||||
if is_scene_path_open(normalized_scene_path):
|
||||
if is_active_scene_path(normalized_scene_path) and force and not dry_run:
|
||||
var root := get_edited_root()
|
||||
var live_changes: Array = []
|
||||
var live_affected_nodes: Array = []
|
||||
_cross_scene_collect_changes(root, root, type_name, property, value, live_affected_nodes, live_changes)
|
||||
if not live_changes.is_empty():
|
||||
_apply_property_changes_with_undo(live_changes, property, "MCP: Cross-scene set %s" % property)
|
||||
scenes_affected.append({
|
||||
"scene": normalized_scene_path,
|
||||
"nodes": live_affected_nodes,
|
||||
"count": live_affected_nodes.size(),
|
||||
"mode": "live_open_scene",
|
||||
})
|
||||
total_nodes += live_affected_nodes.size()
|
||||
else:
|
||||
var reason := "open scene skipped during dry_run" if dry_run else "open scene is not the active editor scene"
|
||||
skipped_open_scenes.append({"scene": normalized_scene_path, "reason": reason})
|
||||
continue
|
||||
|
||||
var packed: PackedScene = ResourceLoader.load(scene_path) as PackedScene
|
||||
if packed == null:
|
||||
continue
|
||||
var instance: Node = packed.instantiate()
|
||||
if instance == null:
|
||||
continue
|
||||
|
||||
var affected_nodes: Array = []
|
||||
var changes: Array = []
|
||||
_cross_scene_collect_changes(instance, instance, type_name, property, value, affected_nodes, changes)
|
||||
|
||||
if not changes.is_empty():
|
||||
if not dry_run:
|
||||
var guard := guard_offline_scene_save(normalized_scene_path)
|
||||
if not guard.is_empty():
|
||||
instance.free()
|
||||
return guard
|
||||
for change: Dictionary in changes:
|
||||
(change["node"] as Node).set(property, value)
|
||||
# Pack and save
|
||||
var new_packed := PackedScene.new()
|
||||
var pack_err := new_packed.pack(instance)
|
||||
if pack_err != OK:
|
||||
instance.free()
|
||||
return error_internal("Failed to pack scene '%s': %s" % [normalized_scene_path, error_string(pack_err)])
|
||||
var save_err := ResourceSaver.save(new_packed, normalized_scene_path)
|
||||
if save_err != OK:
|
||||
instance.free()
|
||||
return error_internal("Failed to save scene '%s': %s" % [normalized_scene_path, error_string(save_err)])
|
||||
scenes_affected.append({
|
||||
"scene": normalized_scene_path,
|
||||
"nodes": affected_nodes,
|
||||
"count": affected_nodes.size(),
|
||||
"mode": "dry_run" if dry_run else "offline_saved",
|
||||
})
|
||||
total_nodes += affected_nodes.size()
|
||||
|
||||
instance.free()
|
||||
|
||||
# Rescan filesystem so editor picks up changes
|
||||
if not scenes_affected.is_empty():
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
|
||||
return success({
|
||||
"type": type_name,
|
||||
"property": property,
|
||||
"dry_run": dry_run,
|
||||
"force": force,
|
||||
"scenes_affected": scenes_affected,
|
||||
"skipped_open_scenes": skipped_open_scenes,
|
||||
"total_scenes": scenes_affected.size(),
|
||||
"total_nodes": total_nodes,
|
||||
"message": "Dry run only. Re-run with force=true and dry_run=false to write closed scenes and live-edit the active open scene." if dry_run else "Changes applied.",
|
||||
})
|
||||
|
||||
|
||||
func _collect_scene_files(path: String, files: Array, exclude_addons: bool) -> void:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
while not file_name.is_empty():
|
||||
if file_name.begins_with("."):
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
var full_path := path.path_join(file_name)
|
||||
if dir.current_is_dir():
|
||||
if exclude_addons and file_name == "addons":
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
_collect_scene_files(full_path, files, exclude_addons)
|
||||
elif file_name.get_extension() == "tscn":
|
||||
files.append(full_path)
|
||||
file_name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func _cross_scene_collect_changes(node: Node, root: Node, type_name: String, property: String, value: Variant, affected: Array, changes: Array) -> void:
|
||||
if node.is_class(type_name) or node.get_class() == type_name:
|
||||
if property in node:
|
||||
affected.append(str(root.get_path_to(node)))
|
||||
changes.append({
|
||||
"node": node,
|
||||
"old_value": node.get(property),
|
||||
"new_value": value,
|
||||
})
|
||||
for child in node.get_children():
|
||||
_cross_scene_collect_changes(child, root, type_name, property, value, affected, changes)
|
||||
|
||||
|
||||
func _apply_property_changes_with_undo(changes: Array, property: String, action_name: String) -> void:
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action(action_name)
|
||||
for change: Dictionary in changes:
|
||||
var node: Node = change["node"]
|
||||
undo_redo.add_do_property(node, property, change["new_value"])
|
||||
undo_redo.add_undo_property(node, property, change["old_value"])
|
||||
undo_redo.commit_action()
|
||||
|
||||
|
||||
func _get_scene_dependencies(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("File '%s'" % path)
|
||||
|
||||
var deps := ResourceLoader.get_dependencies(path)
|
||||
var dependencies: Array = []
|
||||
for dep: String in deps:
|
||||
# Format: "path::type"
|
||||
var parts := dep.split("::")
|
||||
dependencies.append({
|
||||
"path": parts[0] if parts.size() > 0 else dep,
|
||||
"type": parts[2] if parts.size() > 2 else "",
|
||||
})
|
||||
|
||||
return success({"path": path, "dependencies": dependencies, "count": dependencies.size()})
|
||||
@@ -0,0 +1,682 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_editor_errors": _get_editor_errors,
|
||||
"get_output_log": _get_output_log,
|
||||
"get_editor_screenshot": _get_editor_screenshot,
|
||||
"get_game_screenshot": _get_game_screenshot,
|
||||
"execute_editor_script": _execute_editor_script,
|
||||
"clear_output": _clear_output,
|
||||
"reload_plugin": _reload_plugin,
|
||||
"reload_project": _reload_project,
|
||||
"get_signals": _get_signals,
|
||||
"compare_screenshots": _compare_screenshots,
|
||||
"set_auto_dismiss": _set_auto_dismiss,
|
||||
"get_editor_camera": _get_editor_camera,
|
||||
"set_editor_camera": _set_editor_camera,
|
||||
}
|
||||
|
||||
|
||||
func _get_editor_errors(params: Dictionary) -> Dictionary:
|
||||
var errors: Array = []
|
||||
var max_lines: int = optional_int(params, "max_lines", 50)
|
||||
var base: Control = get_editor().get_base_control()
|
||||
|
||||
# 1. Read from the editor's Output panel (EditorLog RichTextLabel)
|
||||
# This captures runtime errors, warnings, and print output
|
||||
var editor_log: Node = base.find_child("Output", true, false)
|
||||
if editor_log:
|
||||
var rtl: RichTextLabel = _find_rtl(editor_log)
|
||||
if rtl:
|
||||
var content: String = rtl.get_parsed_text()
|
||||
var lines: PackedStringArray = content.split("\n")
|
||||
var start: int = maxi(0, lines.size() - max_lines)
|
||||
for i in range(start, lines.size()):
|
||||
var line: String = lines[i]
|
||||
if line.contains("ERROR") or line.contains("SCRIPT ERROR") or line.contains("Parse Error") or line.contains("WARNING"):
|
||||
errors.append(line.strip_edges())
|
||||
|
||||
# 2. Check the script editor for compile errors (red background lines)
|
||||
# These don't appear in the Output panel
|
||||
var script_errors: Array = []
|
||||
var script_editor: ScriptEditor = EditorInterface.get_script_editor()
|
||||
if script_editor:
|
||||
var current_script: Script = script_editor.get_current_script()
|
||||
var ce: CodeEdit = _find_code_edit(script_editor)
|
||||
if ce and current_script:
|
||||
var script_path: String = current_script.resource_path
|
||||
for i in range(ce.get_line_count()):
|
||||
var bg: Color = ce.get_line_background_color(i)
|
||||
if bg.r > 0.8 and bg.a > 0: # Red-ish background = error
|
||||
var line_text: String = ce.get_line(i).strip_edges()
|
||||
script_errors.append("COMPILE ERROR: %s:%d - %s" % [script_path, i + 1, line_text])
|
||||
|
||||
# 3. Read from script editor error/warning panels (GDScript analyzer messages)
|
||||
# Each open script editor has a VSplitContainer with two RichTextLabels:
|
||||
# child[1] = warnings panel, child[2] = errors panel
|
||||
var analyzer_errors: Array = []
|
||||
if script_editor:
|
||||
var open_editors: Array = script_editor.get_open_script_editors()
|
||||
var open_scripts: Array = script_editor.get_open_scripts()
|
||||
for ei in range(open_editors.size()):
|
||||
var editor_node: Node = open_editors[ei]
|
||||
var script_path: String = ""
|
||||
if ei < open_scripts.size() and open_scripts[ei] != null:
|
||||
script_path = (open_scripts[ei] as Resource).resource_path
|
||||
var vsplit: VSplitContainer = null
|
||||
for c in editor_node.get_children():
|
||||
if c is VSplitContainer:
|
||||
vsplit = c as VSplitContainer
|
||||
break
|
||||
if vsplit == null:
|
||||
continue
|
||||
var children: Array = vsplit.get_children()
|
||||
# child[1] = warnings panel (RichTextLabel)
|
||||
if children.size() > 1 and children[1] is RichTextLabel:
|
||||
var text: String = (children[1] as RichTextLabel).get_parsed_text().strip_edges()
|
||||
if not text.is_empty():
|
||||
for line in text.split("\n"):
|
||||
var stripped: String = line.strip_edges()
|
||||
if stripped.is_empty() or stripped == "[Ignore]":
|
||||
continue
|
||||
# Remove leading "[Ignore]" prefix from warning lines
|
||||
stripped = stripped.trim_prefix("[Ignore]")
|
||||
var prefix: String = "WARNING: %s:" % script_path if not script_path.is_empty() else "WARNING: "
|
||||
analyzer_errors.append(prefix + stripped)
|
||||
# child[2] = errors panel (RichTextLabel)
|
||||
if children.size() > 2 and children[2] is RichTextLabel:
|
||||
var text: String = (children[2] as RichTextLabel).get_parsed_text().strip_edges()
|
||||
if not text.is_empty():
|
||||
for line in text.split("\n"):
|
||||
var stripped: String = line.strip_edges()
|
||||
if stripped.is_empty():
|
||||
continue
|
||||
var prefix: String = "SCRIPT ERROR: %s:" % script_path if not script_path.is_empty() else "SCRIPT ERROR: "
|
||||
analyzer_errors.append(prefix + stripped)
|
||||
|
||||
# 4. Read from the debugger Errors tab (runtime errors/warnings)
|
||||
# Path: ScriptEditorDebugger > TabContainer > "Errors" VBoxContainer > Tree
|
||||
var debugger_errors: Array = []
|
||||
var base2: Control = get_editor().get_base_control()
|
||||
if base2:
|
||||
var queue: Array[Node] = [base2]
|
||||
while not queue.is_empty():
|
||||
var node := queue.pop_front()
|
||||
if node.get_class() == "ScriptEditorDebugger":
|
||||
# Find TabContainer inside the debugger
|
||||
for child in node.get_children():
|
||||
if child is TabContainer:
|
||||
var tab_container := child as TabContainer
|
||||
for tab_idx in range(tab_container.get_tab_count()):
|
||||
var tab_control: Control = tab_container.get_tab_control(tab_idx)
|
||||
if tab_control is VBoxContainer and tab_control.name.begins_with("Errors"):
|
||||
# Find Tree inside the Errors tab
|
||||
for vchild in tab_control.get_children():
|
||||
if vchild is Tree:
|
||||
var tree := vchild as Tree
|
||||
var root_item: TreeItem = tree.get_root()
|
||||
if root_item:
|
||||
var item: TreeItem = root_item.get_first_child()
|
||||
while item:
|
||||
var col0: String = item.get_text(0).strip_edges()
|
||||
var col1: String = item.get_text(1).strip_edges()
|
||||
if not col0.is_empty() or not col1.is_empty():
|
||||
var msg: String = col0
|
||||
if not col1.is_empty():
|
||||
msg += " " + col1 if not msg.is_empty() else col1
|
||||
debugger_errors.append("DEBUGGER: " + msg)
|
||||
# Also check child items (expanded error details)
|
||||
var sub: TreeItem = item.get_first_child()
|
||||
while sub:
|
||||
var sub0: String = sub.get_text(0).strip_edges()
|
||||
var sub1: String = sub.get_text(1).strip_edges()
|
||||
if not sub0.is_empty() or not sub1.is_empty():
|
||||
var sub_msg: String = sub0
|
||||
if not sub1.is_empty():
|
||||
sub_msg += " " + sub1 if not sub_msg.is_empty() else sub1
|
||||
debugger_errors.append("DEBUGGER: " + sub_msg)
|
||||
sub = sub.get_next()
|
||||
item = item.get_next()
|
||||
break # Found Errors tab, stop searching tabs
|
||||
break # Found TabContainer, stop searching debugger children
|
||||
break # Found ScriptEditorDebugger, stop BFS
|
||||
for child in node.get_children():
|
||||
queue.append(child)
|
||||
|
||||
# Fallback: read from log file if Output panel not accessible
|
||||
if errors.size() == 0 and script_errors.size() == 0 and analyzer_errors.size() == 0 and debugger_errors.size() == 0:
|
||||
var log_path := "user://logs/godot.log"
|
||||
if FileAccess.file_exists(log_path):
|
||||
var file := FileAccess.open(log_path, FileAccess.READ)
|
||||
if file:
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
var lines := content.split("\n")
|
||||
var start: int = maxi(0, lines.size() - max_lines)
|
||||
for i in range(start, lines.size()):
|
||||
var line: String = lines[i]
|
||||
if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
|
||||
errors.append(line.strip_edges())
|
||||
|
||||
errors.append_array(script_errors)
|
||||
errors.append_array(analyzer_errors)
|
||||
errors.append_array(debugger_errors)
|
||||
return success({"errors": errors, "count": errors.size()})
|
||||
|
||||
|
||||
func _get_output_log(params: Dictionary) -> Dictionary:
|
||||
var max_lines: int = optional_int(params, "max_lines", 100)
|
||||
var filter: String = optional_string(params, "filter", "")
|
||||
var base: Control = get_editor().get_base_control()
|
||||
|
||||
var editor_log: Node = base.find_child("Output", true, false)
|
||||
if editor_log == null:
|
||||
# Fallback: read from log file
|
||||
var log_path := "user://logs/godot.log"
|
||||
if not FileAccess.file_exists(log_path):
|
||||
return error_internal("Output panel not found and no log file available")
|
||||
var file := FileAccess.open(log_path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Cannot read log file")
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
var lines := content.split("\n")
|
||||
var start: int = maxi(0, lines.size() - max_lines)
|
||||
var output_lines: Array = []
|
||||
for i in range(start, lines.size()):
|
||||
var line: String = lines[i]
|
||||
if filter.is_empty() or line.contains(filter):
|
||||
output_lines.append(line)
|
||||
return success({"lines": output_lines, "count": output_lines.size(), "source": "log_file"})
|
||||
|
||||
var rtl: RichTextLabel = _find_rtl(editor_log)
|
||||
if rtl == null:
|
||||
return error_internal("Could not find RichTextLabel in Output panel")
|
||||
|
||||
var content: String = rtl.get_parsed_text()
|
||||
var all_lines: PackedStringArray = content.split("\n")
|
||||
var start: int = maxi(0, all_lines.size() - max_lines)
|
||||
var output_lines: Array = []
|
||||
for i in range(start, all_lines.size()):
|
||||
var line: String = all_lines[i]
|
||||
if filter.is_empty() or line.contains(filter):
|
||||
output_lines.append(line)
|
||||
|
||||
return success({"lines": output_lines, "count": output_lines.size(), "source": "output_panel"})
|
||||
|
||||
|
||||
func _find_code_edit(node: Node, depth: int = 0) -> CodeEdit:
|
||||
if depth > 8:
|
||||
return null
|
||||
if node is CodeEdit:
|
||||
return node as CodeEdit
|
||||
for child in node.get_children():
|
||||
var found: CodeEdit = _find_code_edit(child, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _find_rtl(node: Node, depth: int = 0) -> RichTextLabel:
|
||||
if depth > 6:
|
||||
return null
|
||||
if node is RichTextLabel:
|
||||
return node as RichTextLabel
|
||||
for child in node.get_children():
|
||||
var found: RichTextLabel = _find_rtl(child, depth + 1)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _get_editor_screenshot(params: Dictionary) -> Dictionary:
|
||||
# Capture the editor's main viewport - no await to avoid timeout
|
||||
var base_control: Control = get_editor().get_base_control()
|
||||
if base_control == null:
|
||||
return error_internal("Could not access editor base control")
|
||||
|
||||
var viewport: Viewport = base_control.get_viewport()
|
||||
if viewport == null:
|
||||
return error_internal("Could not access editor viewport")
|
||||
|
||||
var texture: ViewportTexture = viewport.get_texture()
|
||||
if texture == null:
|
||||
return error_internal("Could not get viewport texture")
|
||||
|
||||
var image: Image = texture.get_image()
|
||||
if image == null:
|
||||
return error_internal("Could not get image from viewport")
|
||||
|
||||
var save_path: String = params.get("save_path", "")
|
||||
if save_path != "":
|
||||
var abs_path := _resolve_save_path(save_path)
|
||||
var err := image.save_png(abs_path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to save screenshot: %s" % error_string(err))
|
||||
return success({
|
||||
"saved_path": save_path,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"format": "png",
|
||||
})
|
||||
|
||||
var png_buffer := image.save_png_to_buffer()
|
||||
var base64 := Marshalls.raw_to_base64(png_buffer)
|
||||
|
||||
return success({
|
||||
"image_base64": base64,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"format": "png",
|
||||
})
|
||||
|
||||
|
||||
func _get_game_screenshot(params: Dictionary) -> Dictionary:
|
||||
var ei := get_editor()
|
||||
if not ei.is_playing_scene():
|
||||
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
|
||||
|
||||
# Communicate with the game process via file system
|
||||
var user_dir := get_game_user_dir()
|
||||
var request_path := user_dir + "/mcp_screenshot_request"
|
||||
var screenshot_path := user_dir + "/mcp_screenshot.png"
|
||||
|
||||
# Clean up any stale screenshot file
|
||||
if FileAccess.file_exists(screenshot_path):
|
||||
DirAccess.remove_absolute(screenshot_path)
|
||||
|
||||
# Create the request file to signal the game process
|
||||
var req := FileAccess.open(request_path, FileAccess.WRITE)
|
||||
if req == null:
|
||||
return error_internal("Could not create screenshot request file")
|
||||
req.close()
|
||||
|
||||
# Poll for the screenshot file (max 3 seconds, 0.1s interval)
|
||||
var attempts := 30
|
||||
while attempts > 0:
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
if FileAccess.file_exists(screenshot_path):
|
||||
break
|
||||
attempts -= 1
|
||||
|
||||
if not FileAccess.file_exists(screenshot_path):
|
||||
# Clean up request file if it still exists
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return error(-32000, "Screenshot timed out", {
|
||||
"suggestion": "Ensure the game is running and MCPScreenshot autoload is active",
|
||||
})
|
||||
|
||||
# Load the PNG file
|
||||
var image := Image.new()
|
||||
var err := image.load(screenshot_path)
|
||||
if err != OK:
|
||||
DirAccess.remove_absolute(screenshot_path)
|
||||
return error_internal("Failed to load screenshot: %s" % error_string(err))
|
||||
|
||||
# Clean up temp file
|
||||
DirAccess.remove_absolute(screenshot_path)
|
||||
|
||||
var save_path_param: String = params.get("save_path", "")
|
||||
if save_path_param != "":
|
||||
var abs_path := _resolve_save_path(save_path_param)
|
||||
var save_err := image.save_png(abs_path)
|
||||
if save_err != OK:
|
||||
return error_internal("Failed to save screenshot: %s" % error_string(save_err))
|
||||
return success({
|
||||
"saved_path": save_path_param,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"format": "png",
|
||||
})
|
||||
|
||||
var png_buffer := image.save_png_to_buffer()
|
||||
var base64 := Marshalls.raw_to_base64(png_buffer)
|
||||
|
||||
return success({
|
||||
"image_base64": base64,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"format": "png",
|
||||
})
|
||||
|
||||
|
||||
func _resolve_save_path(path: String) -> String:
|
||||
if path.begins_with("res://") or path.begins_with("user://"):
|
||||
return ProjectSettings.globalize_path(path)
|
||||
return path
|
||||
|
||||
|
||||
func _execute_editor_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "code")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var code: String = result[0]
|
||||
var allow_unsafe_editor_io: bool = optional_bool(params, "allow_unsafe_editor_io", false)
|
||||
var unsafe_guard := _guard_editor_script_file_io(code, allow_unsafe_editor_io)
|
||||
if not unsafe_guard.is_empty():
|
||||
return unsafe_guard
|
||||
|
||||
# Wrap user code in a @tool script
|
||||
var wrapped_code := """@tool
|
||||
extends Node
|
||||
|
||||
var _mcp_output: Array = []
|
||||
|
||||
func _mcp_print(value: Variant) -> void:
|
||||
_mcp_output.append(str(value))
|
||||
|
||||
func run() -> Variant:
|
||||
# User code begins
|
||||
%s
|
||||
# User code ends
|
||||
return _mcp_output
|
||||
""" % _indent_code(code)
|
||||
|
||||
# Create a temporary script
|
||||
var script := GDScript.new()
|
||||
script.source_code = wrapped_code
|
||||
var err := script.reload()
|
||||
|
||||
if err != OK:
|
||||
return error(-32002, "Script compilation failed", {
|
||||
"error": error_string(err),
|
||||
"code": wrapped_code,
|
||||
})
|
||||
|
||||
# Create temp node and execute
|
||||
var temp_node := Node.new()
|
||||
temp_node.set_script(script)
|
||||
add_child(temp_node)
|
||||
|
||||
var output: Variant = null
|
||||
|
||||
# Execute with error handling
|
||||
if temp_node.has_method("run"):
|
||||
output = temp_node.run()
|
||||
|
||||
var mcp_output: Array = []
|
||||
var raw_output: Variant = temp_node.get("_mcp_output")
|
||||
if raw_output is Array:
|
||||
mcp_output = raw_output
|
||||
|
||||
# Cleanup
|
||||
temp_node.queue_free()
|
||||
|
||||
return success({
|
||||
"output": mcp_output,
|
||||
"return_value": str(output) if output != null else null,
|
||||
})
|
||||
|
||||
|
||||
func _guard_editor_script_file_io(code: String, allow_unsafe_editor_io: bool) -> Dictionary:
|
||||
if allow_unsafe_editor_io:
|
||||
return {}
|
||||
var compact := code.replace(" ", "").replace("\t", "").replace("\n", "")
|
||||
var unsafe_patterns: Array[String] = []
|
||||
if compact.contains("ResourceSaver.save("):
|
||||
unsafe_patterns.append("ResourceSaver.save")
|
||||
if compact.contains("ProjectSettings.save("):
|
||||
unsafe_patterns.append("ProjectSettings.save")
|
||||
if compact.contains("ConfigFile.save("):
|
||||
unsafe_patterns.append("ConfigFile.save")
|
||||
if compact.contains("FileAccess.open(") and _contains_any(compact, ["FileAccess.WRITE", "FileAccess.READ_WRITE", "FileAccess.WRITE_READ"]):
|
||||
unsafe_patterns.append("FileAccess.open WRITE")
|
||||
if _contains_any(compact, ["DirAccess.remove_absolute(", "DirAccess.rename_absolute(", "DirAccess.copy_absolute(", "DirAccess.make_dir_absolute(", "DirAccess.make_dir_recursive_absolute("]):
|
||||
unsafe_patterns.append("DirAccess filesystem mutation")
|
||||
if unsafe_patterns.is_empty():
|
||||
return {}
|
||||
return error_conflict(
|
||||
"Refusing to execute editor script with direct file/resource write APIs",
|
||||
{
|
||||
"unsafe_patterns": unsafe_patterns,
|
||||
"open_scenes": get_open_scene_paths(),
|
||||
"suggestion": "Use dedicated MCP commands and save_scene for editor-owned resources, or pass allow_unsafe_editor_io=true only when no open editor resource can be overwritten.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
func _contains_any(value: String, needles: Array[String]) -> bool:
|
||||
for needle: String in needles:
|
||||
if value.contains(needle):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _indent_code(code: String) -> String:
|
||||
var lines := code.split("\n")
|
||||
var indented: PackedStringArray = []
|
||||
for line in lines:
|
||||
indented.append("\t" + line)
|
||||
return "\n".join(indented)
|
||||
|
||||
|
||||
func _clear_output(params: Dictionary) -> Dictionary:
|
||||
print("\n".repeat(50))
|
||||
return success({"cleared": true})
|
||||
|
||||
|
||||
func _reload_plugin(params: Dictionary) -> Dictionary:
|
||||
# Disable and re-enable this plugin to reload all scripts
|
||||
var plugin_name := "godot_mcp"
|
||||
var ei := get_editor()
|
||||
|
||||
# Send success BEFORE reloading (connection will briefly drop)
|
||||
# Use call_deferred so the response is sent first
|
||||
_deferred_reload_plugin.call_deferred(ei, plugin_name)
|
||||
return success({"reloading": true, "message": "Plugin will reload momentarily. Connection will briefly drop and auto-reconnect."})
|
||||
|
||||
|
||||
func _deferred_reload_plugin(ei: EditorInterface, plugin_name: String) -> void:
|
||||
ei.set_plugin_enabled(plugin_name, false)
|
||||
ei.set_plugin_enabled(plugin_name, true)
|
||||
print("[MCP] Plugin reloaded")
|
||||
|
||||
|
||||
func _reload_project(params: Dictionary) -> Dictionary:
|
||||
# Rescan filesystem and reload changed scripts
|
||||
var ei := get_editor()
|
||||
ei.get_resource_filesystem().scan()
|
||||
|
||||
return success({"reloaded": true, "message": "Filesystem rescanned."})
|
||||
|
||||
|
||||
func _get_signals(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path)
|
||||
|
||||
var signals: Array = []
|
||||
for sig in node.get_signal_list():
|
||||
var sig_info: Dictionary = {
|
||||
"name": sig["name"],
|
||||
"args": [],
|
||||
}
|
||||
for arg in sig["args"]:
|
||||
sig_info["args"].append({"name": arg["name"], "type": arg["type"]})
|
||||
|
||||
# Get connections for this signal
|
||||
var connections: Array = []
|
||||
for conn in node.get_signal_connection_list(sig["name"]):
|
||||
connections.append({
|
||||
"target": str(root.get_path_to(conn["callable"].get_object())),
|
||||
"method": conn["callable"].get_method(),
|
||||
})
|
||||
sig_info["connections"] = connections
|
||||
signals.append(sig_info)
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"type": node.get_class(),
|
||||
"signals": signals,
|
||||
"count": signals.size(),
|
||||
})
|
||||
|
||||
|
||||
func _load_image_from_param(value: String, label: String) -> Array:
|
||||
## Returns [Image, null] on success or [null, error_dict] on failure.
|
||||
## Accepts a file path (res://, user://) or raw base64 PNG data.
|
||||
var img := Image.new()
|
||||
if value.begins_with("res://") or value.begins_with("user://"):
|
||||
var err := img.load(value)
|
||||
if err != OK:
|
||||
return [null, error_invalid_params("Failed to load %s from path '%s': %s" % [label, value, error_string(err)])]
|
||||
return [img, null]
|
||||
# Treat as base64 PNG
|
||||
var buf := Marshalls.base64_to_raw(value)
|
||||
var err := img.load_png_from_buffer(buf)
|
||||
if err != OK:
|
||||
return [null, error_invalid_params("Failed to decode %s from base64: %s" % [label, error_string(err)])]
|
||||
return [img, null]
|
||||
|
||||
|
||||
func _compare_screenshots(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "image_a")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var image_a_value: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "image_b")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var image_b_value: String = result2[0]
|
||||
|
||||
var threshold: int = optional_int(params, "threshold", 10)
|
||||
|
||||
# Load images (from path or base64)
|
||||
var load_a := _load_image_from_param(image_a_value, "image_a")
|
||||
if load_a[1] != null:
|
||||
return load_a[1]
|
||||
var img_a: Image = load_a[0]
|
||||
|
||||
var load_b := _load_image_from_param(image_b_value, "image_b")
|
||||
if load_b[1] != null:
|
||||
return load_b[1]
|
||||
var img_b: Image = load_b[0]
|
||||
|
||||
if img_a.get_size() != img_b.get_size():
|
||||
return error_invalid_params("Image sizes differ: %s vs %s" % [str(img_a.get_size()), str(img_b.get_size())])
|
||||
|
||||
var width := img_a.get_width()
|
||||
var height := img_a.get_height()
|
||||
var diff_image := Image.create(width, height, false, Image.FORMAT_RGBA8)
|
||||
|
||||
var changed_pixels: int = 0
|
||||
var total_pixels: int = width * height
|
||||
|
||||
for y in height:
|
||||
for x in width:
|
||||
var ca: Color = img_a.get_pixel(x, y)
|
||||
var cb: Color = img_b.get_pixel(x, y)
|
||||
var dr := absi(int(ca.r8) - int(cb.r8))
|
||||
var dg := absi(int(ca.g8) - int(cb.g8))
|
||||
var db := absi(int(ca.b8) - int(cb.b8))
|
||||
var max_diff := maxi(dr, maxi(dg, db))
|
||||
if max_diff > threshold:
|
||||
changed_pixels += 1
|
||||
# Red highlight for changed pixels
|
||||
diff_image.set_pixel(x, y, Color(1, 0, 0, clampf(float(max_diff) / 255.0, 0.3, 1.0)))
|
||||
else:
|
||||
# Dim version of original
|
||||
diff_image.set_pixel(x, y, Color(ca.r * 0.3, ca.g * 0.3, ca.b * 0.3, 1.0))
|
||||
|
||||
var diff_percentage: float = (float(changed_pixels) / float(total_pixels)) * 100.0
|
||||
var identical: bool = changed_pixels == 0
|
||||
|
||||
# Encode diff image
|
||||
var diff_png := diff_image.save_png_to_buffer()
|
||||
var diff_base64 := Marshalls.raw_to_base64(diff_png)
|
||||
|
||||
return success({
|
||||
"identical": identical,
|
||||
"changed_pixels": changed_pixels,
|
||||
"total_pixels": total_pixels,
|
||||
"diff_percentage": snappedf(diff_percentage, 0.01),
|
||||
"threshold": threshold,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"diff_image_base64": diff_base64,
|
||||
})
|
||||
|
||||
|
||||
func _get_editor_camera(_params: Dictionary) -> Dictionary:
|
||||
var vp3d := EditorInterface.get_editor_viewport_3d()
|
||||
var cam := vp3d.get_camera_3d() if vp3d else null
|
||||
if not cam:
|
||||
return error(-32000, "No 3D editor camera found", {
|
||||
"suggestion": "Make sure a 3D scene is open in the editor",
|
||||
})
|
||||
var pos := cam.global_position
|
||||
var rot := cam.rotation_degrees
|
||||
return success({
|
||||
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
|
||||
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
|
||||
"fov": cam.fov,
|
||||
"near": cam.near,
|
||||
"far": cam.far,
|
||||
})
|
||||
|
||||
|
||||
func _set_editor_camera(params: Dictionary) -> Dictionary:
|
||||
var vp3d := EditorInterface.get_editor_viewport_3d()
|
||||
var cam := vp3d.get_camera_3d() if vp3d else null
|
||||
if not cam:
|
||||
return error(-32000, "No 3D editor camera found", {
|
||||
"suggestion": "Make sure a 3D scene is open in the editor",
|
||||
})
|
||||
|
||||
# Set position
|
||||
if params.has("position"):
|
||||
var p: Dictionary = params["position"]
|
||||
cam.global_position = Vector3(
|
||||
float(p.get("x", cam.global_position.x)),
|
||||
float(p.get("y", cam.global_position.y)),
|
||||
float(p.get("z", cam.global_position.z)),
|
||||
)
|
||||
|
||||
# Set rotation
|
||||
if params.has("rotation_degrees"):
|
||||
var r: Dictionary = params["rotation_degrees"]
|
||||
cam.rotation_degrees = Vector3(
|
||||
float(r.get("x", cam.rotation_degrees.x)),
|
||||
float(r.get("y", cam.rotation_degrees.y)),
|
||||
float(r.get("z", cam.rotation_degrees.z)),
|
||||
)
|
||||
|
||||
# Look at target (overrides rotation if set)
|
||||
if params.has("look_at"):
|
||||
var t: Dictionary = params["look_at"]
|
||||
cam.look_at(Vector3(float(t.get("x", 0)), float(t.get("y", 0)), float(t.get("z", 0))))
|
||||
|
||||
# Set FOV
|
||||
if params.has("fov"):
|
||||
cam.fov = float(params["fov"])
|
||||
|
||||
var pos := cam.global_position
|
||||
var rot := cam.rotation_degrees
|
||||
return success({
|
||||
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
|
||||
"rotation_degrees": {"x": rot.x, "y": rot.y, "z": rot.z},
|
||||
"fov": cam.fov,
|
||||
})
|
||||
|
||||
|
||||
func _set_auto_dismiss(params: Dictionary) -> Dictionary:
|
||||
var enabled: bool = params.get("enabled", true)
|
||||
editor_plugin.auto_dismiss_dialogs = enabled
|
||||
return success({
|
||||
"auto_dismiss": enabled,
|
||||
"message": "Auto-dismiss dialogs %s" % ("enabled" if enabled else "disabled"),
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"list_export_presets": _list_export_presets,
|
||||
"export_project": _export_project,
|
||||
"get_export_info": _get_export_info,
|
||||
}
|
||||
|
||||
|
||||
func _list_export_presets(params: Dictionary) -> Dictionary:
|
||||
# Read export_presets.cfg
|
||||
var presets_path := "res://export_presets.cfg"
|
||||
if not FileAccess.file_exists(presets_path):
|
||||
return success({"presets": [], "count": 0, "message": "No export_presets.cfg found"})
|
||||
|
||||
var cfg := ConfigFile.new()
|
||||
var err := cfg.load(presets_path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to read export_presets.cfg: %s" % error_string(err))
|
||||
|
||||
var presets: Array = []
|
||||
var idx := 0
|
||||
while cfg.has_section("preset.%d" % idx):
|
||||
var section := "preset.%d" % idx
|
||||
presets.append({
|
||||
"index": idx,
|
||||
"name": cfg.get_value(section, "name", ""),
|
||||
"platform": cfg.get_value(section, "platform", ""),
|
||||
"runnable": cfg.get_value(section, "runnable", false),
|
||||
"export_path": cfg.get_value(section, "export_path", ""),
|
||||
})
|
||||
idx += 1
|
||||
|
||||
return success({"presets": presets, "count": presets.size()})
|
||||
|
||||
|
||||
func _export_project(params: Dictionary) -> Dictionary:
|
||||
var preset_index: int = optional_int(params, "preset_index", -1)
|
||||
var preset_name: String = optional_string(params, "preset_name", "")
|
||||
var debug: bool = optional_bool(params, "debug", true)
|
||||
|
||||
# Find preset
|
||||
var presets_path := "res://export_presets.cfg"
|
||||
if not FileAccess.file_exists(presets_path):
|
||||
return error(-32000, "No export_presets.cfg found. Configure exports in Project > Export first.")
|
||||
|
||||
var cfg := ConfigFile.new()
|
||||
var err := cfg.load(presets_path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to read export_presets.cfg")
|
||||
|
||||
# Find by name or index
|
||||
var target_section := ""
|
||||
var target_name := ""
|
||||
var target_path := ""
|
||||
|
||||
if not preset_name.is_empty():
|
||||
var idx := 0
|
||||
while cfg.has_section("preset.%d" % idx):
|
||||
var section := "preset.%d" % idx
|
||||
if cfg.get_value(section, "name", "") == preset_name:
|
||||
target_section = section
|
||||
target_name = preset_name
|
||||
target_path = cfg.get_value(section, "export_path", "")
|
||||
break
|
||||
idx += 1
|
||||
elif preset_index >= 0:
|
||||
var section := "preset.%d" % preset_index
|
||||
if cfg.has_section(section):
|
||||
target_section = section
|
||||
target_name = cfg.get_value(section, "name", "")
|
||||
target_path = cfg.get_value(section, "export_path", "")
|
||||
|
||||
if target_section.is_empty():
|
||||
return error_not_found("Export preset")
|
||||
|
||||
if target_path.is_empty():
|
||||
return error(-32000, "Export path not configured for preset '%s'" % target_name)
|
||||
|
||||
# Use EditorExportPlatform via command line
|
||||
# We can't directly call export from the plugin, so we return the command to run
|
||||
var godot_path := OS.get_executable_path()
|
||||
var project_path := ProjectSettings.globalize_path("res://")
|
||||
var export_path := ProjectSettings.globalize_path(target_path) if target_path.begins_with("res://") else target_path
|
||||
|
||||
var flag := "--export-debug" if debug else "--export-release"
|
||||
var command := '"%s" --headless --path "%s" %s "%s"' % [godot_path, project_path, flag, target_name]
|
||||
|
||||
return success({
|
||||
"preset": target_name,
|
||||
"export_path": export_path,
|
||||
"debug": debug,
|
||||
"command": command,
|
||||
"message": "Run the command above to export. Direct export from editor plugin is not supported in Godot 4.",
|
||||
})
|
||||
|
||||
|
||||
func _get_export_info(params: Dictionary) -> Dictionary:
|
||||
# General export-related project info
|
||||
var info := {}
|
||||
|
||||
# Check if export_presets.cfg exists
|
||||
info["has_export_presets"] = FileAccess.file_exists("res://export_presets.cfg")
|
||||
|
||||
# Get Godot executable path (useful for command-line exports)
|
||||
info["godot_executable"] = OS.get_executable_path()
|
||||
info["project_path"] = ProjectSettings.globalize_path("res://")
|
||||
|
||||
# Check for common export templates
|
||||
var templates_path := OS.get_data_dir().path_join("export_templates")
|
||||
info["templates_dir"] = templates_path
|
||||
info["templates_installed"] = DirAccess.dir_exists_absolute(templates_path)
|
||||
|
||||
return success(info)
|
||||
@@ -0,0 +1,162 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
const COMMANDS_PATH := "user://mcp_input_commands"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"simulate_key": _simulate_key,
|
||||
"simulate_mouse_click": _simulate_mouse_click,
|
||||
"simulate_mouse_move": _simulate_mouse_move,
|
||||
"simulate_action": _simulate_action,
|
||||
"simulate_sequence": _simulate_sequence,
|
||||
}
|
||||
|
||||
|
||||
func _simulate_key(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "keycode")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var keycode: String = result[0]
|
||||
|
||||
var pressed: bool = optional_bool(params, "pressed", true)
|
||||
var shift: bool = optional_bool(params, "shift", false)
|
||||
var ctrl: bool = optional_bool(params, "ctrl", false)
|
||||
var alt: bool = optional_bool(params, "alt", false)
|
||||
|
||||
var event := {
|
||||
"type": "key",
|
||||
"keycode": keycode,
|
||||
"pressed": pressed,
|
||||
"shift": shift,
|
||||
"ctrl": ctrl,
|
||||
"alt": alt,
|
||||
}
|
||||
_write_commands([event])
|
||||
return success({"sent": true, "event": event})
|
||||
|
||||
|
||||
func _simulate_mouse_click(params: Dictionary) -> Dictionary:
|
||||
var button: int = optional_int(params, "button", 1) # MOUSE_BUTTON_LEFT
|
||||
var pressed: bool = optional_bool(params, "pressed", true)
|
||||
var double_click: bool = optional_bool(params, "double_click", false)
|
||||
var auto_release: bool = optional_bool(params, "auto_release", true)
|
||||
var x: float = float(params.get("x", 0))
|
||||
var y: float = float(params.get("y", 0))
|
||||
|
||||
var press_event := {
|
||||
"type": "mouse_button",
|
||||
"button": button,
|
||||
"pressed": pressed,
|
||||
"double_click": double_click,
|
||||
"position": {"x": x, "y": y},
|
||||
}
|
||||
|
||||
# Auto-release: send press + release in sequence so UI buttons actually fire
|
||||
if pressed and auto_release:
|
||||
var release_event := press_event.duplicate()
|
||||
release_event["pressed"] = false
|
||||
var sequence_data := {
|
||||
"sequence_events": [press_event, release_event],
|
||||
"frame_delay": 1,
|
||||
}
|
||||
var json := JSON.stringify(sequence_data)
|
||||
var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return error_internal("Failed to write commands: %s" % error_string(FileAccess.get_open_error()))
|
||||
file.store_string(json)
|
||||
file.close()
|
||||
return success({"sent": true, "event": press_event, "auto_release": true})
|
||||
|
||||
_write_commands([press_event])
|
||||
return success({"sent": true, "event": press_event})
|
||||
|
||||
|
||||
func _simulate_mouse_move(params: Dictionary) -> Dictionary:
|
||||
var x: float = float(params.get("x", 0))
|
||||
var y: float = float(params.get("y", 0))
|
||||
var rel_x: float = float(params.get("relative_x", 0))
|
||||
var rel_y: float = float(params.get("relative_y", 0))
|
||||
var button_mask: int = optional_int(params, "button_mask", 0)
|
||||
var unhandled_explicit: bool = params.has("unhandled")
|
||||
var unhandled: bool = optional_bool(params, "unhandled", false)
|
||||
|
||||
var event := {
|
||||
"type": "mouse_motion",
|
||||
"position": {"x": x, "y": y},
|
||||
"relative": {"x": rel_x, "y": rel_y},
|
||||
"button_mask": button_mask,
|
||||
}
|
||||
# Auto-enable unhandled for drag motions (camera-pan use case) ONLY when
|
||||
# the caller did NOT explicitly pass an "unhandled" key. If they passed
|
||||
# one — true or false — honor it. This lets UI drag-and-drop tests opt
|
||||
# back into normal GUI dispatch by passing unhandled: false explicitly.
|
||||
if unhandled_explicit:
|
||||
event["unhandled"] = unhandled
|
||||
elif button_mask > 0:
|
||||
event["unhandled"] = true
|
||||
_write_commands([event])
|
||||
return success({"sent": true, "event": event})
|
||||
|
||||
|
||||
func _simulate_action(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "action")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var action_name: String = result[0]
|
||||
|
||||
var pressed: bool = optional_bool(params, "pressed", true)
|
||||
var strength: float = float(params.get("strength", 1.0))
|
||||
|
||||
var event := {
|
||||
"type": "action",
|
||||
"action": action_name,
|
||||
"pressed": pressed,
|
||||
"strength": strength,
|
||||
}
|
||||
_write_commands([event])
|
||||
return success({"sent": true, "event": event})
|
||||
|
||||
|
||||
func _simulate_sequence(params: Dictionary) -> Dictionary:
|
||||
if not params.has("events") or not params["events"] is Array:
|
||||
return error_invalid_params("Missing required parameter: events (Array)")
|
||||
|
||||
var events: Array = params["events"]
|
||||
if events.is_empty():
|
||||
return error_invalid_params("Events array is empty")
|
||||
|
||||
var frame_delay: int = optional_int(params, "frame_delay", 1)
|
||||
|
||||
for event_data: Dictionary in events:
|
||||
if not event_data.has("type") or (event_data["type"] as String).is_empty():
|
||||
return error_invalid_params("Invalid event in sequence: %s" % str(event_data))
|
||||
|
||||
if frame_delay <= 0:
|
||||
# All events in one frame - write as plain array
|
||||
_write_commands(events)
|
||||
else:
|
||||
# Sequence with frame delay - game side handles timing
|
||||
var sequence_data := {
|
||||
"sequence_events": events,
|
||||
"frame_delay": frame_delay,
|
||||
}
|
||||
var json := JSON.stringify(sequence_data)
|
||||
var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return error_internal("Failed to write commands: %s" % error_string(FileAccess.get_open_error()))
|
||||
file.store_string(json)
|
||||
file.close()
|
||||
|
||||
return success({"sent": true, "event_count": events.size(), "frame_delay": frame_delay})
|
||||
|
||||
|
||||
func _write_commands(events: Array) -> void:
|
||||
var json := JSON.stringify(events)
|
||||
var file := FileAccess.open(COMMANDS_PATH, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_error("[MCP Input] Failed to write commands: %s" % error_string(FileAccess.get_open_error()))
|
||||
return
|
||||
file.store_string(json)
|
||||
file.close()
|
||||
@@ -0,0 +1,151 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_input_actions": _get_input_actions,
|
||||
"set_input_action": _set_input_action,
|
||||
}
|
||||
|
||||
|
||||
func _get_input_actions(params: Dictionary) -> Dictionary:
|
||||
var filter: String = optional_string(params, "filter", "")
|
||||
var include_builtin: bool = optional_bool(params, "include_builtin", false)
|
||||
|
||||
var actions: Dictionary = {}
|
||||
for action: StringName in InputMap.get_actions():
|
||||
var action_str := str(action)
|
||||
# Skip built-in UI actions unless requested
|
||||
if not include_builtin and action_str.begins_with("ui_"):
|
||||
continue
|
||||
# Apply filter
|
||||
if not filter.is_empty() and not action_str.contains(filter):
|
||||
continue
|
||||
|
||||
var events: Array = []
|
||||
for event: InputEvent in InputMap.action_get_events(action):
|
||||
events.append(_serialize_event(event))
|
||||
|
||||
actions[action_str] = {
|
||||
"deadzone": InputMap.action_get_deadzone(action),
|
||||
"events": events,
|
||||
}
|
||||
|
||||
return success({"actions": actions, "count": actions.size()})
|
||||
|
||||
|
||||
func _set_input_action(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "action")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var action_name: String = result[0]
|
||||
|
||||
if not params.has("events") or not params["events"] is Array:
|
||||
return error_invalid_params("'events' array is required")
|
||||
var event_defs: Array = params["events"]
|
||||
|
||||
var deadzone: float = float(params.get("deadzone", 0.5))
|
||||
|
||||
# Build the events array
|
||||
var events: Array[InputEvent] = []
|
||||
for event_def in event_defs:
|
||||
if not event_def is Dictionary:
|
||||
continue
|
||||
var event := _parse_event(event_def)
|
||||
if event != null:
|
||||
events.append(event)
|
||||
|
||||
# Save to ProjectSettings
|
||||
var setting_value := {
|
||||
"deadzone": deadzone,
|
||||
"events": events,
|
||||
}
|
||||
|
||||
ProjectSettings.set_setting("input/" + action_name, setting_value)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return error_internal("Failed to save project settings: %s" % error_string(err))
|
||||
|
||||
# Also update the runtime InputMap
|
||||
if not InputMap.has_action(action_name):
|
||||
InputMap.add_action(action_name, deadzone)
|
||||
else:
|
||||
InputMap.action_set_deadzone(action_name, deadzone)
|
||||
InputMap.action_erase_events(action_name)
|
||||
for event in events:
|
||||
InputMap.action_add_event(action_name, event)
|
||||
|
||||
return success({
|
||||
"action": action_name,
|
||||
"deadzone": deadzone,
|
||||
"events_count": events.size(),
|
||||
"saved": true,
|
||||
})
|
||||
|
||||
|
||||
func _serialize_event(event: InputEvent) -> Dictionary:
|
||||
if event is InputEventKey:
|
||||
var key_event: InputEventKey = event
|
||||
var info := {
|
||||
"type": "key",
|
||||
"keycode": OS.get_keycode_string(key_event.keycode) if key_event.keycode != KEY_NONE else "",
|
||||
"physical_keycode": OS.get_keycode_string(key_event.physical_keycode) if key_event.physical_keycode != KEY_NONE else "",
|
||||
}
|
||||
if key_event.ctrl_pressed: info["ctrl"] = true
|
||||
if key_event.shift_pressed: info["shift"] = true
|
||||
if key_event.alt_pressed: info["alt"] = true
|
||||
if key_event.meta_pressed: info["meta"] = true
|
||||
return info
|
||||
elif event is InputEventMouseButton:
|
||||
var mb_event: InputEventMouseButton = event
|
||||
return {
|
||||
"type": "mouse_button",
|
||||
"button_index": mb_event.button_index,
|
||||
}
|
||||
elif event is InputEventJoypadButton:
|
||||
var jb_event: InputEventJoypadButton = event
|
||||
return {
|
||||
"type": "joypad_button",
|
||||
"button_index": jb_event.button_index,
|
||||
}
|
||||
elif event is InputEventJoypadMotion:
|
||||
var jm_event: InputEventJoypadMotion = event
|
||||
return {
|
||||
"type": "joypad_motion",
|
||||
"axis": jm_event.axis,
|
||||
"axis_value": jm_event.axis_value,
|
||||
}
|
||||
return {"type": event.get_class()}
|
||||
|
||||
|
||||
func _parse_event(def: Dictionary) -> InputEvent:
|
||||
var type: String = def.get("type", "")
|
||||
match type:
|
||||
"key":
|
||||
var event := InputEventKey.new()
|
||||
var keycode_str: String = def.get("keycode", "")
|
||||
if not keycode_str.is_empty():
|
||||
event.keycode = OS.find_keycode_from_string(keycode_str)
|
||||
var phys_str: String = def.get("physical_keycode", "")
|
||||
if not phys_str.is_empty():
|
||||
event.physical_keycode = OS.find_keycode_from_string(phys_str)
|
||||
event.ctrl_pressed = def.get("ctrl", false)
|
||||
event.shift_pressed = def.get("shift", false)
|
||||
event.alt_pressed = def.get("alt", false)
|
||||
event.meta_pressed = def.get("meta", false)
|
||||
return event
|
||||
"mouse_button":
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = int(def.get("button_index", 1))
|
||||
return event
|
||||
"joypad_button":
|
||||
var event := InputEventJoypadButton.new()
|
||||
event.button_index = int(def.get("button_index", 0))
|
||||
return event
|
||||
"joypad_motion":
|
||||
var event := InputEventJoypadMotion.new()
|
||||
event.axis = int(def.get("axis", 0))
|
||||
event.axis_value = float(def.get("axis_value", 1.0))
|
||||
return event
|
||||
return null
|
||||
@@ -0,0 +1,472 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"setup_navigation_region": _setup_navigation_region,
|
||||
"bake_navigation_mesh": _bake_navigation_mesh,
|
||||
"setup_navigation_agent": _setup_navigation_agent,
|
||||
"set_navigation_layers": _set_navigation_layers,
|
||||
"get_navigation_info": _get_navigation_info,
|
||||
}
|
||||
|
||||
|
||||
func _is_3d_context(node: Node) -> bool:
|
||||
if node is Node3D:
|
||||
return true
|
||||
if node is Node2D:
|
||||
return false
|
||||
# Walk up to detect context
|
||||
var parent := node.get_parent()
|
||||
while parent != null:
|
||||
if parent is Node3D:
|
||||
return true
|
||||
if parent is Node2D:
|
||||
return false
|
||||
parent = parent.get_parent()
|
||||
return false
|
||||
|
||||
|
||||
func _setup_navigation_region(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var force_mode: String = optional_string(params, "mode", "auto")
|
||||
var is_3d: bool
|
||||
match force_mode:
|
||||
"2d": is_3d = false
|
||||
"3d": is_3d = true
|
||||
_: is_3d = _is_3d_context(node)
|
||||
|
||||
if is_3d:
|
||||
var region := NavigationRegion3D.new()
|
||||
region.name = optional_string(params, "name", "NavigationRegion3D")
|
||||
|
||||
var nav_mesh := NavigationMesh.new()
|
||||
nav_mesh.agent_radius = float(params.get("agent_radius", 0.5))
|
||||
nav_mesh.agent_height = float(params.get("agent_height", 1.5))
|
||||
nav_mesh.agent_max_climb = float(params.get("agent_max_climb", 0.25))
|
||||
nav_mesh.agent_max_slope = float(params.get("agent_max_slope", 45.0))
|
||||
nav_mesh.cell_size = float(params.get("cell_size", 0.25))
|
||||
nav_mesh.cell_height = float(params.get("cell_height", 0.25))
|
||||
region.navigation_mesh = nav_mesh
|
||||
|
||||
if params.has("navigation_layers"):
|
||||
region.navigation_layers = int(params["navigation_layers"])
|
||||
|
||||
add_child_with_undo(node, region, root, "MCP: Add NavigationRegion3D")
|
||||
|
||||
return success({
|
||||
"node_path": str(region.get_path()),
|
||||
"type": "NavigationRegion3D",
|
||||
"agent_radius": nav_mesh.agent_radius,
|
||||
"agent_height": nav_mesh.agent_height,
|
||||
"cell_size": nav_mesh.cell_size,
|
||||
"created": true,
|
||||
})
|
||||
else:
|
||||
var region := NavigationRegion2D.new()
|
||||
region.name = optional_string(params, "name", "NavigationRegion2D")
|
||||
|
||||
var nav_poly := NavigationPolygon.new()
|
||||
|
||||
# Set parsed geometry source if available
|
||||
if params.has("source_geometry_mode"):
|
||||
var mode_str: String = str(params["source_geometry_mode"])
|
||||
match mode_str:
|
||||
"root_node": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_ROOT_NODE_CHILDREN
|
||||
"groups_with_children": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_WITH_CHILDREN
|
||||
"groups_explicit": nav_poly.source_geometry_mode = NavigationPolygon.SOURCE_GEOMETRY_GROUPS_EXPLICIT
|
||||
|
||||
if params.has("cell_size"):
|
||||
nav_poly.cell_size = float(params["cell_size"])
|
||||
|
||||
if params.has("agent_radius"):
|
||||
nav_poly.agent_radius = float(params["agent_radius"])
|
||||
|
||||
region.navigation_polygon = nav_poly
|
||||
|
||||
if params.has("navigation_layers"):
|
||||
region.navigation_layers = int(params["navigation_layers"])
|
||||
|
||||
add_child_with_undo(node, region, root, "MCP: Add NavigationRegion2D")
|
||||
|
||||
return success({
|
||||
"node_path": str(region.get_path()),
|
||||
"type": "NavigationRegion2D",
|
||||
"cell_size": nav_poly.cell_size,
|
||||
"created": true,
|
||||
})
|
||||
|
||||
|
||||
func _bake_navigation_mesh(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
if node is NavigationRegion3D:
|
||||
var region: NavigationRegion3D = node as NavigationRegion3D
|
||||
if region.navigation_mesh == null:
|
||||
return error_invalid_params("NavigationRegion3D has no NavigationMesh resource")
|
||||
region.bake_navigation_mesh()
|
||||
mark_current_scene_unsaved()
|
||||
return success({
|
||||
"node_path": node_path,
|
||||
"type": "NavigationRegion3D",
|
||||
"baked": true,
|
||||
})
|
||||
|
||||
elif node is NavigationRegion2D:
|
||||
var region: NavigationRegion2D = node as NavigationRegion2D
|
||||
if region.navigation_polygon == null:
|
||||
var nav_poly := NavigationPolygon.new()
|
||||
region.navigation_polygon = nav_poly
|
||||
|
||||
# Set outline vertices from params
|
||||
if params.has("outline"):
|
||||
var outline_data: Array = params["outline"]
|
||||
var outline := PackedVector2Array()
|
||||
for point in outline_data:
|
||||
if point is Array and point.size() >= 2:
|
||||
outline.append(Vector2(float(point[0]), float(point[1])))
|
||||
elif point is Dictionary:
|
||||
outline.append(Vector2(float(point.get("x", 0)), float(point.get("y", 0))))
|
||||
|
||||
if outline.size() >= 3:
|
||||
# Clear existing outlines
|
||||
while region.navigation_polygon.get_outline_count() > 0:
|
||||
region.navigation_polygon.remove_outline(0)
|
||||
region.navigation_polygon.add_outline(outline)
|
||||
region.navigation_polygon.make_polygons_from_outlines()
|
||||
mark_current_scene_unsaved()
|
||||
return success({
|
||||
"node_path": node_path,
|
||||
"type": "NavigationRegion2D",
|
||||
"outline_vertices": outline.size(),
|
||||
"baked": true,
|
||||
})
|
||||
else:
|
||||
return error_invalid_params("Outline must have at least 3 vertices")
|
||||
else:
|
||||
# Try baking from source geometry
|
||||
region.bake_navigation_polygon()
|
||||
mark_current_scene_unsaved()
|
||||
return success({
|
||||
"node_path": node_path,
|
||||
"type": "NavigationRegion2D",
|
||||
"baked": true,
|
||||
})
|
||||
|
||||
return error_invalid_params("Node '%s' is not a NavigationRegion2D or NavigationRegion3D" % node_path)
|
||||
|
||||
|
||||
func _setup_navigation_agent(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var force_mode: String = optional_string(params, "mode", "auto")
|
||||
var is_3d: bool
|
||||
match force_mode:
|
||||
"2d": is_3d = false
|
||||
"3d": is_3d = true
|
||||
_: is_3d = _is_3d_context(node)
|
||||
|
||||
var agent_name: String = optional_string(params, "name", "NavigationAgent3D" if is_3d else "NavigationAgent2D")
|
||||
|
||||
if is_3d:
|
||||
var agent := NavigationAgent3D.new()
|
||||
agent.name = agent_name
|
||||
|
||||
if params.has("path_desired_distance"):
|
||||
agent.path_desired_distance = float(params["path_desired_distance"])
|
||||
if params.has("target_desired_distance"):
|
||||
agent.target_desired_distance = float(params["target_desired_distance"])
|
||||
if params.has("radius"):
|
||||
agent.radius = float(params["radius"])
|
||||
if params.has("neighbor_distance"):
|
||||
agent.neighbor_distance = float(params["neighbor_distance"])
|
||||
if params.has("max_neighbors"):
|
||||
agent.max_neighbors = int(params["max_neighbors"])
|
||||
if params.has("max_speed"):
|
||||
agent.max_speed = float(params["max_speed"])
|
||||
if params.has("avoidance_enabled"):
|
||||
agent.avoidance_enabled = bool(params["avoidance_enabled"])
|
||||
if params.has("navigation_layers"):
|
||||
agent.navigation_layers = int(params["navigation_layers"])
|
||||
|
||||
add_child_with_undo(node, agent, root, "MCP: Add NavigationAgent3D")
|
||||
|
||||
return success({
|
||||
"node_path": str(agent.get_path()),
|
||||
"type": "NavigationAgent3D",
|
||||
"radius": agent.radius,
|
||||
"max_speed": agent.max_speed,
|
||||
"avoidance_enabled": agent.avoidance_enabled,
|
||||
"navigation_layers": agent.navigation_layers,
|
||||
"created": true,
|
||||
})
|
||||
else:
|
||||
var agent := NavigationAgent2D.new()
|
||||
agent.name = agent_name
|
||||
|
||||
if params.has("path_desired_distance"):
|
||||
agent.path_desired_distance = float(params["path_desired_distance"])
|
||||
if params.has("target_desired_distance"):
|
||||
agent.target_desired_distance = float(params["target_desired_distance"])
|
||||
if params.has("radius"):
|
||||
agent.radius = float(params["radius"])
|
||||
if params.has("neighbor_distance"):
|
||||
agent.neighbor_distance = float(params["neighbor_distance"])
|
||||
if params.has("max_neighbors"):
|
||||
agent.max_neighbors = int(params["max_neighbors"])
|
||||
if params.has("max_speed"):
|
||||
agent.max_speed = float(params["max_speed"])
|
||||
if params.has("avoidance_enabled"):
|
||||
agent.avoidance_enabled = bool(params["avoidance_enabled"])
|
||||
if params.has("navigation_layers"):
|
||||
agent.navigation_layers = int(params["navigation_layers"])
|
||||
|
||||
add_child_with_undo(node, agent, root, "MCP: Add NavigationAgent2D")
|
||||
|
||||
return success({
|
||||
"node_path": str(agent.get_path()),
|
||||
"type": "NavigationAgent2D",
|
||||
"radius": agent.radius,
|
||||
"max_speed": agent.max_speed,
|
||||
"avoidance_enabled": agent.avoidance_enabled,
|
||||
"navigation_layers": agent.navigation_layers,
|
||||
"created": true,
|
||||
})
|
||||
|
||||
|
||||
func _set_navigation_layers(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
|
||||
# Support setting by bitmask value
|
||||
if params.has("layers"):
|
||||
var layers_val: int = int(params["layers"])
|
||||
if node is NavigationRegion2D:
|
||||
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
|
||||
elif node is NavigationRegion3D:
|
||||
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
|
||||
elif node is NavigationAgent2D:
|
||||
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
|
||||
elif node is NavigationAgent3D:
|
||||
set_property_with_undo(node, "navigation_layers", layers_val, "MCP: Set navigation layers")
|
||||
else:
|
||||
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
|
||||
|
||||
return success({
|
||||
"node_path": node_path,
|
||||
"navigation_layers": layers_val,
|
||||
"updated": true,
|
||||
})
|
||||
|
||||
# Support setting individual layer bits by number
|
||||
if params.has("layer_bits"):
|
||||
var bits: Array = params["layer_bits"]
|
||||
var current_layers: int = 0
|
||||
|
||||
# Calculate bitmask from layer numbers (1-based)
|
||||
for bit in bits:
|
||||
var layer_num: int = int(bit)
|
||||
if layer_num >= 1 and layer_num <= 32:
|
||||
current_layers |= (1 << (layer_num - 1))
|
||||
|
||||
if node is NavigationRegion2D:
|
||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
||||
elif node is NavigationRegion3D:
|
||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
||||
elif node is NavigationAgent2D:
|
||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
||||
elif node is NavigationAgent3D:
|
||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
||||
else:
|
||||
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
|
||||
|
||||
return success({
|
||||
"node_path": node_path,
|
||||
"navigation_layers": current_layers,
|
||||
"layer_bits": bits,
|
||||
"updated": true,
|
||||
})
|
||||
|
||||
# Support named layers from ProjectSettings
|
||||
if params.has("layer_names"):
|
||||
var names: Array = params["layer_names"]
|
||||
var current_layers: int = 0
|
||||
var is_2d: bool = node is NavigationRegion2D or node is NavigationAgent2D
|
||||
var prefix: String = "layer_names/2d_navigation/layer_" if is_2d else "layer_names/3d_navigation/layer_"
|
||||
|
||||
for i in range(1, 33):
|
||||
var setting_key: String = prefix + str(i)
|
||||
if ProjectSettings.has_setting(setting_key):
|
||||
var layer_name: String = str(ProjectSettings.get_setting(setting_key))
|
||||
if layer_name in names:
|
||||
current_layers |= (1 << (i - 1))
|
||||
|
||||
if node is NavigationRegion2D:
|
||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
||||
elif node is NavigationRegion3D:
|
||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
||||
elif node is NavigationAgent2D:
|
||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
||||
elif node is NavigationAgent3D:
|
||||
set_property_with_undo(node, "navigation_layers", current_layers, "MCP: Set navigation layers")
|
||||
else:
|
||||
return error_invalid_params("Node '%s' is not a navigation region or agent" % node_path)
|
||||
|
||||
return success({
|
||||
"node_path": node_path,
|
||||
"navigation_layers": current_layers,
|
||||
"layer_names": names,
|
||||
"updated": true,
|
||||
})
|
||||
|
||||
return error_invalid_params("Must provide 'layers' (bitmask), 'layer_bits' (array of layer numbers), or 'layer_names' (array of named layers)")
|
||||
|
||||
|
||||
func _get_navigation_info(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
|
||||
var regions: Array = []
|
||||
var agents: Array = []
|
||||
|
||||
_collect_navigation_nodes(node, regions, agents)
|
||||
|
||||
# Collect named layers from ProjectSettings
|
||||
var layer_names_2d: Dictionary = {}
|
||||
var layer_names_3d: Dictionary = {}
|
||||
for i in range(1, 33):
|
||||
var key_2d: String = "layer_names/2d_navigation/layer_" + str(i)
|
||||
var key_3d: String = "layer_names/3d_navigation/layer_" + str(i)
|
||||
if ProjectSettings.has_setting(key_2d):
|
||||
var name_2d: String = str(ProjectSettings.get_setting(key_2d))
|
||||
if not name_2d.is_empty():
|
||||
layer_names_2d[i] = name_2d
|
||||
if ProjectSettings.has_setting(key_3d):
|
||||
var name_3d: String = str(ProjectSettings.get_setting(key_3d))
|
||||
if not name_3d.is_empty():
|
||||
layer_names_3d[i] = name_3d
|
||||
|
||||
return success({
|
||||
"node_path": node_path,
|
||||
"regions": regions,
|
||||
"agents": agents,
|
||||
"region_count": regions.size(),
|
||||
"agent_count": agents.size(),
|
||||
"layer_names_2d": layer_names_2d,
|
||||
"layer_names_3d": layer_names_3d,
|
||||
})
|
||||
|
||||
|
||||
func _collect_navigation_nodes(node: Node, regions: Array, agents: Array) -> void:
|
||||
if node is NavigationRegion2D:
|
||||
var region: NavigationRegion2D = node as NavigationRegion2D
|
||||
var region_info := {
|
||||
"path": str(region.get_path()),
|
||||
"type": "NavigationRegion2D",
|
||||
"enabled": region.enabled,
|
||||
"navigation_layers": region.navigation_layers,
|
||||
"has_polygon": region.navigation_polygon != null,
|
||||
}
|
||||
if region.navigation_polygon != null:
|
||||
var nav_poly: NavigationPolygon = region.navigation_polygon
|
||||
region_info["outline_count"] = nav_poly.get_outline_count()
|
||||
region_info["polygon_count"] = nav_poly.get_polygon_count()
|
||||
region_info["cell_size"] = nav_poly.cell_size
|
||||
region_info["agent_radius"] = nav_poly.agent_radius
|
||||
regions.append(region_info)
|
||||
|
||||
elif node is NavigationRegion3D:
|
||||
var region: NavigationRegion3D = node as NavigationRegion3D
|
||||
var region_info := {
|
||||
"path": str(region.get_path()),
|
||||
"type": "NavigationRegion3D",
|
||||
"enabled": region.enabled,
|
||||
"navigation_layers": region.navigation_layers,
|
||||
"has_mesh": region.navigation_mesh != null,
|
||||
}
|
||||
if region.navigation_mesh != null:
|
||||
var nav_mesh: NavigationMesh = region.navigation_mesh
|
||||
region_info["agent_radius"] = nav_mesh.agent_radius
|
||||
region_info["agent_height"] = nav_mesh.agent_height
|
||||
region_info["agent_max_climb"] = nav_mesh.agent_max_climb
|
||||
region_info["agent_max_slope"] = nav_mesh.agent_max_slope
|
||||
region_info["cell_size"] = nav_mesh.cell_size
|
||||
region_info["cell_height"] = nav_mesh.cell_height
|
||||
regions.append(region_info)
|
||||
|
||||
if node is NavigationAgent2D:
|
||||
var agent: NavigationAgent2D = node as NavigationAgent2D
|
||||
agents.append({
|
||||
"path": str(agent.get_path()),
|
||||
"type": "NavigationAgent2D",
|
||||
"radius": agent.radius,
|
||||
"max_speed": agent.max_speed,
|
||||
"path_desired_distance": agent.path_desired_distance,
|
||||
"target_desired_distance": agent.target_desired_distance,
|
||||
"neighbor_distance": agent.neighbor_distance,
|
||||
"max_neighbors": agent.max_neighbors,
|
||||
"avoidance_enabled": agent.avoidance_enabled,
|
||||
"navigation_layers": agent.navigation_layers,
|
||||
})
|
||||
|
||||
elif node is NavigationAgent3D:
|
||||
var agent: NavigationAgent3D = node as NavigationAgent3D
|
||||
agents.append({
|
||||
"path": str(agent.get_path()),
|
||||
"type": "NavigationAgent3D",
|
||||
"radius": agent.radius,
|
||||
"max_speed": agent.max_speed,
|
||||
"path_desired_distance": agent.path_desired_distance,
|
||||
"target_desired_distance": agent.target_desired_distance,
|
||||
"neighbor_distance": agent.neighbor_distance,
|
||||
"max_neighbors": agent.max_neighbors,
|
||||
"avoidance_enabled": agent.avoidance_enabled,
|
||||
"navigation_layers": agent.navigation_layers,
|
||||
})
|
||||
|
||||
for child in node.get_children():
|
||||
_collect_navigation_nodes(child, regions, agents)
|
||||
@@ -0,0 +1,706 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd")
|
||||
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"add_node": _add_node,
|
||||
"delete_node": _delete_node,
|
||||
"duplicate_node": _duplicate_node,
|
||||
"move_node": _move_node,
|
||||
"update_property": _update_property,
|
||||
"get_node_properties": _get_node_properties,
|
||||
"add_resource": _add_resource,
|
||||
"set_anchor_preset": _set_anchor_preset,
|
||||
"rename_node": _rename_node,
|
||||
"connect_signal": _connect_signal,
|
||||
"disconnect_signal": _disconnect_signal,
|
||||
"get_node_groups": _get_node_groups,
|
||||
"set_node_groups": _set_node_groups,
|
||||
"find_nodes_in_group": _find_nodes_in_group,
|
||||
}
|
||||
|
||||
|
||||
func _find_script_by_class_name(class_name_str: String) -> Script:
|
||||
# Search project files for a script with matching class_name
|
||||
var global_classes: Array = ProjectSettings.get_global_class_list()
|
||||
for entry: Dictionary in global_classes:
|
||||
if entry.get("class", "") == class_name_str:
|
||||
var path: String = entry.get("path", "")
|
||||
if not path.is_empty():
|
||||
return load(path) as Script
|
||||
return null
|
||||
|
||||
|
||||
func _add_node(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "type")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var type: String = result[0]
|
||||
|
||||
var parent_path: String = optional_string(params, "parent_path", ".")
|
||||
var node_name: String = optional_string(params, "name", "")
|
||||
var properties: Dictionary = params.get("properties", {})
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var parent := find_node_by_path(parent_path)
|
||||
if parent == null:
|
||||
return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
var node: Node
|
||||
var custom_script: Script = null
|
||||
|
||||
if ClassDB.class_exists(type):
|
||||
node = ClassDB.instantiate(type)
|
||||
else:
|
||||
# Try to find a script with matching class_name
|
||||
custom_script = _find_script_by_class_name(type)
|
||||
if custom_script == null:
|
||||
return error_invalid_params("Unknown node type: '%s'. Not found in ClassDB or as a script class_name. Use list_scripts to see available script classes." % type)
|
||||
var base_type: String = custom_script.get_instance_base_type()
|
||||
if not ClassDB.class_exists(base_type):
|
||||
return error_invalid_params("Script '%s' extends '%s' which is not a valid node type" % [type, base_type])
|
||||
node = ClassDB.instantiate(base_type)
|
||||
node.set_script(custom_script)
|
||||
if not node_name.is_empty():
|
||||
node.name = node_name
|
||||
|
||||
# Apply properties
|
||||
for prop_name: String in properties:
|
||||
var prop_exists := false
|
||||
for prop in node.get_property_list():
|
||||
if prop["name"] == prop_name:
|
||||
prop_exists = true
|
||||
break
|
||||
if prop_exists:
|
||||
var current: Variant = node.get(prop_name)
|
||||
var target_type := typeof(current)
|
||||
node.set(prop_name, PropertyParser.parse_value(properties[prop_name], target_type))
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Add %s" % type)
|
||||
undo_redo.add_do_method(parent, "add_child", node)
|
||||
undo_redo.add_do_method(node, "set_owner", root)
|
||||
undo_redo.add_do_reference(node)
|
||||
undo_redo.add_undo_method(parent, "remove_child", node)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"type": type,
|
||||
"name": str(node.name),
|
||||
})
|
||||
|
||||
|
||||
func _delete_node(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
if node == root:
|
||||
return error_invalid_params("Cannot delete the root node")
|
||||
|
||||
var parent := node.get_parent()
|
||||
var node_name := str(node.name)
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Delete %s" % node_name)
|
||||
undo_redo.add_do_method(parent, "remove_child", node)
|
||||
undo_redo.add_undo_method(parent, "add_child", node)
|
||||
undo_redo.add_undo_method(node, "set_owner", root)
|
||||
undo_redo.add_undo_reference(node)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"deleted": node_name})
|
||||
|
||||
|
||||
func _duplicate_node(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var new_name: String = optional_string(params, "name", "")
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
if new_name.is_empty():
|
||||
new_name = str(node.name) + "_copy"
|
||||
|
||||
var dup := node.duplicate()
|
||||
dup.name = new_name
|
||||
var parent := node.get_parent()
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Duplicate %s" % node.name)
|
||||
undo_redo.add_do_method(parent, "add_child", dup)
|
||||
undo_redo.add_do_method(dup, "set_owner", root)
|
||||
undo_redo.add_do_reference(dup)
|
||||
undo_redo.add_undo_method(parent, "remove_child", dup)
|
||||
undo_redo.commit_action()
|
||||
|
||||
NodeUtils.set_owner_recursive(dup, root)
|
||||
|
||||
return success({
|
||||
"original": str(root.get_path_to(node)),
|
||||
"duplicate": str(root.get_path_to(dup)),
|
||||
"name": str(dup.name),
|
||||
})
|
||||
|
||||
|
||||
func _move_node(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "new_parent_path")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var new_parent_path: String = result2[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
if node == root:
|
||||
return error_invalid_params("Cannot move the root node")
|
||||
|
||||
var new_parent := find_node_by_path(new_parent_path)
|
||||
if new_parent == null:
|
||||
return error_not_found("Target parent '%s'" % new_parent_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
# Check we're not moving a node into its own subtree
|
||||
if new_parent == node or node.is_ancestor_of(new_parent):
|
||||
return error_invalid_params("Cannot move a node into its own subtree")
|
||||
|
||||
var old_parent := node.get_parent()
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Move %s" % node.name)
|
||||
undo_redo.add_do_method(old_parent, "remove_child", node)
|
||||
undo_redo.add_do_method(new_parent, "add_child", node)
|
||||
undo_redo.add_do_method(node, "set_owner", root)
|
||||
undo_redo.add_undo_method(new_parent, "remove_child", node)
|
||||
undo_redo.add_undo_method(old_parent, "add_child", node)
|
||||
undo_redo.add_undo_method(node, "set_owner", root)
|
||||
undo_redo.commit_action()
|
||||
|
||||
NodeUtils.set_owner_recursive(node, root)
|
||||
|
||||
return success({
|
||||
"node": str(node.name),
|
||||
"old_parent": str(root.get_path_to(old_parent)),
|
||||
"new_parent": str(root.get_path_to(new_parent)),
|
||||
"new_path": str(root.get_path_to(node)),
|
||||
})
|
||||
|
||||
|
||||
func _update_property(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "property")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var property: String = result2[0]
|
||||
|
||||
if not params.has("value"):
|
||||
return error_invalid_params("Missing required parameter: value")
|
||||
var value: Variant = params["value"]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
# Check property exists
|
||||
if not property in node:
|
||||
var available: Array = []
|
||||
for prop in node.get_property_list():
|
||||
if prop["usage"] & PROPERTY_USAGE_EDITOR:
|
||||
available.append(prop["name"])
|
||||
return error_not_found("Property '%s' on %s" % [property, node.get_class()],
|
||||
"Available: %s" % str(available.slice(0, 20)))
|
||||
|
||||
var old_value: Variant = node.get(property)
|
||||
var target_type := typeof(old_value)
|
||||
var parsed_value: Variant = PropertyParser.parse_value(value, target_type)
|
||||
|
||||
# Handle @export node references (e.g. @export var hud: HUD)
|
||||
# typeof() returns TYPE_NIL when unset or TYPE_OBJECT when set,
|
||||
# neither resolves a string path to a node — check the property hint instead
|
||||
if value is String:
|
||||
for prop in node.get_property_list():
|
||||
if prop["name"] == property and prop["hint"] == PROPERTY_HINT_NODE_TYPE:
|
||||
var target_node: Node = node.get_node_or_null(NodePath(value))
|
||||
if target_node == null:
|
||||
target_node = root.get_node_or_null(NodePath(value))
|
||||
if target_node == null:
|
||||
return error_not_found("Node '%s'" % value, "Could not resolve node path for property '%s'" % property)
|
||||
parsed_value = target_node
|
||||
break
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set %s.%s" % [node.name, property])
|
||||
undo_redo.add_do_property(node, property, parsed_value)
|
||||
undo_redo.add_undo_property(node, property, old_value)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node": str(root.get_path_to(node)),
|
||||
"property": property,
|
||||
"old_value": PropertyParser.serialize_value(old_value),
|
||||
"new_value": PropertyParser.serialize_value(node.get(property)),
|
||||
})
|
||||
|
||||
|
||||
func _get_node_properties(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
var category: String = optional_string(params, "category", "")
|
||||
var props := NodeUtils.get_node_properties_dict(node)
|
||||
|
||||
# Filter by category if specified
|
||||
if not category.is_empty():
|
||||
var filtered: Dictionary = {}
|
||||
for key: String in props:
|
||||
if key.begins_with(category):
|
||||
filtered[key] = props[key]
|
||||
props = filtered
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"type": node.get_class(),
|
||||
"properties": props,
|
||||
})
|
||||
|
||||
|
||||
func _add_resource(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "property")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var property: String = result2[0]
|
||||
|
||||
var result3 := require_string(params, "resource_type")
|
||||
if result3[1] != null:
|
||||
return result3[1]
|
||||
var resource_type: String = result3[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
if not ClassDB.class_exists(resource_type):
|
||||
return error_invalid_params("Unknown resource type: %s" % resource_type)
|
||||
|
||||
if not ClassDB.is_parent_class(resource_type, "Resource"):
|
||||
return error_invalid_params("'%s' is not a Resource type" % resource_type)
|
||||
|
||||
var resource: Resource = ClassDB.instantiate(resource_type)
|
||||
if resource == null:
|
||||
return error_internal("Failed to create resource: %s" % resource_type)
|
||||
|
||||
# Apply resource properties if provided
|
||||
var resource_props: Dictionary = params.get("resource_properties", {})
|
||||
for prop_name: String in resource_props:
|
||||
if prop_name in resource:
|
||||
var current: Variant = resource.get(prop_name)
|
||||
resource.set(prop_name, PropertyParser.parse_value(resource_props[prop_name], typeof(current)))
|
||||
|
||||
var old_value: Variant = node.get(property) if property in node else null
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Add %s to %s" % [resource_type, node.name])
|
||||
undo_redo.add_do_property(node, property, resource)
|
||||
undo_redo.add_undo_property(node, property, old_value)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"property": property,
|
||||
"resource_type": resource_type,
|
||||
})
|
||||
|
||||
|
||||
func _set_anchor_preset(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "preset")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var preset_name: String = result2[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
if not node is Control:
|
||||
return error_invalid_params("Node '%s' is not a Control (is %s)" % [node_path, node.get_class()])
|
||||
|
||||
var control: Control = node
|
||||
var presets := {
|
||||
"top_left": Control.PRESET_TOP_LEFT,
|
||||
"top_right": Control.PRESET_TOP_RIGHT,
|
||||
"bottom_left": Control.PRESET_BOTTOM_LEFT,
|
||||
"bottom_right": Control.PRESET_BOTTOM_RIGHT,
|
||||
"center_left": Control.PRESET_CENTER_LEFT,
|
||||
"center_top": Control.PRESET_CENTER_TOP,
|
||||
"center_right": Control.PRESET_CENTER_RIGHT,
|
||||
"center_bottom": Control.PRESET_CENTER_BOTTOM,
|
||||
"center": Control.PRESET_CENTER,
|
||||
"left_wide": Control.PRESET_LEFT_WIDE,
|
||||
"top_wide": Control.PRESET_TOP_WIDE,
|
||||
"right_wide": Control.PRESET_RIGHT_WIDE,
|
||||
"bottom_wide": Control.PRESET_BOTTOM_WIDE,
|
||||
"vcenter_wide": Control.PRESET_VCENTER_WIDE,
|
||||
"hcenter_wide": Control.PRESET_HCENTER_WIDE,
|
||||
"full_rect": Control.PRESET_FULL_RECT,
|
||||
}
|
||||
|
||||
if not presets.has(preset_name):
|
||||
return error_invalid_params("Unknown preset: '%s'. Available: %s" % [preset_name, presets.keys()])
|
||||
|
||||
var keep_offsets: bool = optional_bool(params, "keep_offsets", false)
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set anchor preset on %s" % node.name)
|
||||
|
||||
# Store old values
|
||||
var old_anchors := [control.anchor_left, control.anchor_top, control.anchor_right, control.anchor_bottom]
|
||||
var old_offsets := [control.offset_left, control.offset_top, control.offset_right, control.offset_bottom]
|
||||
|
||||
var target: Control = control.duplicate() as Control
|
||||
target.set_anchors_and_offsets_preset(presets[preset_name],
|
||||
Control.PRESET_MODE_KEEP_SIZE if keep_offsets else Control.PRESET_MODE_MINSIZE)
|
||||
|
||||
undo_redo.add_do_property(control, "anchor_left", target.anchor_left)
|
||||
undo_redo.add_do_property(control, "anchor_top", target.anchor_top)
|
||||
undo_redo.add_do_property(control, "anchor_right", target.anchor_right)
|
||||
undo_redo.add_do_property(control, "anchor_bottom", target.anchor_bottom)
|
||||
undo_redo.add_do_property(control, "offset_left", target.offset_left)
|
||||
undo_redo.add_do_property(control, "offset_top", target.offset_top)
|
||||
undo_redo.add_do_property(control, "offset_right", target.offset_right)
|
||||
undo_redo.add_do_property(control, "offset_bottom", target.offset_bottom)
|
||||
|
||||
undo_redo.add_undo_property(control, "anchor_left", old_anchors[0])
|
||||
undo_redo.add_undo_property(control, "anchor_top", old_anchors[1])
|
||||
undo_redo.add_undo_property(control, "anchor_right", old_anchors[2])
|
||||
undo_redo.add_undo_property(control, "anchor_bottom", old_anchors[3])
|
||||
undo_redo.add_undo_property(control, "offset_left", old_offsets[0])
|
||||
undo_redo.add_undo_property(control, "offset_top", old_offsets[1])
|
||||
undo_redo.add_undo_property(control, "offset_right", old_offsets[2])
|
||||
undo_redo.add_undo_property(control, "offset_bottom", old_offsets[3])
|
||||
|
||||
target.free()
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"node_path": str(root.get_path_to(control)), "preset": preset_name})
|
||||
|
||||
|
||||
func _rename_node(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "new_name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var new_name: String = result2[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
var old_name: String = node.name
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Rename %s to %s" % [old_name, new_name])
|
||||
undo_redo.add_do_property(node, "name", new_name)
|
||||
undo_redo.add_undo_property(node, "name", old_name)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"old_name": old_name, "new_name": str(node.name), "node_path": str(root.get_path_to(node))})
|
||||
|
||||
|
||||
func _connect_signal(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "source_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var source_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "signal_name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var signal_name: String = result2[0]
|
||||
|
||||
var result3 := require_string(params, "target_path")
|
||||
if result3[1] != null:
|
||||
return result3[1]
|
||||
var target_path: String = result3[0]
|
||||
|
||||
var result4 := require_string(params, "method_name")
|
||||
if result4[1] != null:
|
||||
return result4[1]
|
||||
var method_name: String = result4[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var source := find_node_by_path(source_path)
|
||||
if source == null:
|
||||
return error_not_found("Source node '%s'" % source_path)
|
||||
|
||||
var target := find_node_by_path(target_path)
|
||||
if target == null:
|
||||
return error_not_found("Target node '%s'" % target_path)
|
||||
|
||||
if not source.has_signal(signal_name):
|
||||
return error_invalid_params("Signal '%s' not found on %s" % [signal_name, source.get_class()])
|
||||
|
||||
if source.is_connected(signal_name, Callable(target, method_name)):
|
||||
return success({"already_connected": true, "signal": signal_name})
|
||||
|
||||
var callable := Callable(target, method_name)
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Connect signal")
|
||||
undo_redo.add_do_method(source, "connect", signal_name, callable)
|
||||
undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"source": str(root.get_path_to(source)),
|
||||
"signal": signal_name,
|
||||
"target": str(root.get_path_to(target)),
|
||||
"method": method_name,
|
||||
"connected": true,
|
||||
})
|
||||
|
||||
|
||||
func _disconnect_signal(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "source_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var source_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "signal_name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var signal_name: String = result2[0]
|
||||
|
||||
var result3 := require_string(params, "target_path")
|
||||
if result3[1] != null:
|
||||
return result3[1]
|
||||
var target_path: String = result3[0]
|
||||
|
||||
var result4 := require_string(params, "method_name")
|
||||
if result4[1] != null:
|
||||
return result4[1]
|
||||
var method_name: String = result4[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var source := find_node_by_path(source_path)
|
||||
if source == null:
|
||||
return error_not_found("Source node '%s'" % source_path)
|
||||
|
||||
var target := find_node_by_path(target_path)
|
||||
if target == null:
|
||||
return error_not_found("Target node '%s'" % target_path)
|
||||
|
||||
if not source.is_connected(signal_name, Callable(target, method_name)):
|
||||
return success({"was_connected": false})
|
||||
|
||||
var callable := Callable(target, method_name)
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Disconnect signal")
|
||||
undo_redo.add_do_method(source, "disconnect", signal_name, callable)
|
||||
undo_redo.add_undo_method(source, "connect", signal_name, callable)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"source": str(root.get_path_to(source)),
|
||||
"signal": signal_name,
|
||||
"target": str(root.get_path_to(target)),
|
||||
"method": method_name,
|
||||
"disconnected": true,
|
||||
})
|
||||
|
||||
|
||||
func _get_node_groups(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
var groups: Array = []
|
||||
for group: StringName in node.get_groups():
|
||||
var g := str(group)
|
||||
# Filter out internal groups (start with _)
|
||||
if not g.begins_with("_"):
|
||||
groups.append(g)
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"groups": groups,
|
||||
"count": groups.size(),
|
||||
})
|
||||
|
||||
|
||||
func _set_node_groups(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
if not params.has("groups") or not params["groups"] is Array:
|
||||
return error_invalid_params("'groups' array is required")
|
||||
var desired_groups: Array = params["groups"]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
# Get current non-internal groups
|
||||
var current_groups: Array = []
|
||||
for group: StringName in node.get_groups():
|
||||
var g := str(group)
|
||||
if not g.begins_with("_"):
|
||||
current_groups.append(g)
|
||||
|
||||
var added: Array = []
|
||||
var removed: Array = []
|
||||
|
||||
for group: String in current_groups:
|
||||
if group not in desired_groups:
|
||||
removed.append(group)
|
||||
|
||||
for group in desired_groups:
|
||||
var g: String = str(group)
|
||||
if g not in current_groups:
|
||||
added.append(g)
|
||||
|
||||
if not added.is_empty() or not removed.is_empty():
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set node groups")
|
||||
for group: String in removed:
|
||||
undo_redo.add_do_method(node, "remove_from_group", group)
|
||||
undo_redo.add_undo_method(node, "add_to_group", group, true)
|
||||
for group: String in added:
|
||||
undo_redo.add_do_method(node, "add_to_group", group, true)
|
||||
undo_redo.add_undo_method(node, "remove_from_group", group)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"groups": desired_groups,
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
})
|
||||
|
||||
|
||||
func _find_nodes_in_group(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "group")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var group_name: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var matches: Array = []
|
||||
_find_in_group_recursive(root, root, group_name, matches)
|
||||
|
||||
return success({
|
||||
"group": group_name,
|
||||
"nodes": matches,
|
||||
"count": matches.size(),
|
||||
})
|
||||
|
||||
|
||||
func _find_in_group_recursive(node: Node, root: Node, group_name: String, matches: Array) -> void:
|
||||
if node.is_in_group(group_name):
|
||||
matches.append({
|
||||
"name": node.name,
|
||||
"path": str(root.get_path_to(node)),
|
||||
"type": node.get_class(),
|
||||
})
|
||||
for child in node.get_children():
|
||||
_find_in_group_recursive(child, root, group_name, matches)
|
||||
@@ -0,0 +1,612 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"create_particles": _create_particles,
|
||||
"set_particle_material": _set_particle_material,
|
||||
"set_particle_color_gradient": _set_particle_color_gradient,
|
||||
"apply_particle_preset": _apply_particle_preset,
|
||||
"get_particle_info": _get_particle_info,
|
||||
}
|
||||
|
||||
|
||||
func _get_particles_node(node_path: String) -> GPUParticles2D:
|
||||
# Returns any GPUParticles2D or GPUParticles3D (both share similar API)
|
||||
var node := find_node_by_path(node_path)
|
||||
if node is GPUParticles2D:
|
||||
return node as GPUParticles2D
|
||||
return null
|
||||
|
||||
|
||||
func _get_particles_node_any(node_path: String) -> Node:
|
||||
var node := find_node_by_path(node_path)
|
||||
if node is GPUParticles2D or node is GPUParticles3D:
|
||||
return node
|
||||
return null
|
||||
|
||||
|
||||
func _parse_color(color_str: String) -> Color:
|
||||
# Support hex "#RRGGBB", "#RRGGBBAA", or named colors
|
||||
if color_str.begins_with("#"):
|
||||
return Color.html(color_str)
|
||||
# Try named color
|
||||
match color_str.to_lower():
|
||||
"red": return Color.RED
|
||||
"green": return Color.GREEN
|
||||
"blue": return Color.BLUE
|
||||
"white": return Color.WHITE
|
||||
"black": return Color.BLACK
|
||||
"yellow": return Color.YELLOW
|
||||
"orange": return Color(1.0, 0.5, 0.0)
|
||||
"gray", "grey": return Color.GRAY
|
||||
"cyan": return Color.CYAN
|
||||
"magenta": return Color.MAGENTA
|
||||
"transparent": return Color(0, 0, 0, 0)
|
||||
# Try Expression parser for Color(r,g,b,a)
|
||||
var expr := Expression.new()
|
||||
if expr.parse(color_str) == OK:
|
||||
var parsed = expr.execute()
|
||||
if parsed is Color:
|
||||
return parsed
|
||||
return Color.WHITE
|
||||
|
||||
|
||||
func _create_particles(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "parent_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var parent_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var parent := find_node_by_path(parent_path)
|
||||
if parent == null:
|
||||
return error_not_found("Node at '%s'" % parent_path)
|
||||
|
||||
var node_name: String = optional_string(params, "name", "Particles")
|
||||
var is_3d: bool = optional_bool(params, "is_3d", false)
|
||||
var amount: int = optional_int(params, "amount", 16)
|
||||
var lifetime: float = float(params.get("lifetime", 1.0))
|
||||
var one_shot: bool = optional_bool(params, "one_shot", false)
|
||||
var explosiveness: float = float(params.get("explosiveness", 0.0))
|
||||
var randomness: float = float(params.get("randomness", 0.0))
|
||||
var emitting: bool = optional_bool(params, "emitting", true)
|
||||
|
||||
var particles_node: Node
|
||||
if is_3d:
|
||||
var p := GPUParticles3D.new()
|
||||
p.name = node_name
|
||||
p.amount = amount
|
||||
p.lifetime = lifetime
|
||||
p.one_shot = one_shot
|
||||
p.explosiveness = explosiveness
|
||||
p.randomness = randomness
|
||||
p.emitting = emitting
|
||||
var mat := ParticleProcessMaterial.new()
|
||||
p.process_material = mat
|
||||
particles_node = p
|
||||
else:
|
||||
var p := GPUParticles2D.new()
|
||||
p.name = node_name
|
||||
p.amount = amount
|
||||
p.lifetime = lifetime
|
||||
p.one_shot = one_shot
|
||||
p.explosiveness = explosiveness
|
||||
p.randomness = randomness
|
||||
p.emitting = emitting
|
||||
var mat := ParticleProcessMaterial.new()
|
||||
p.process_material = mat
|
||||
particles_node = p
|
||||
|
||||
add_child_with_undo(parent, particles_node, root, "MCP: Create particles")
|
||||
|
||||
return success({
|
||||
"name": particles_node.name,
|
||||
"parent": parent_path,
|
||||
"is_3d": is_3d,
|
||||
"amount": amount,
|
||||
"lifetime": lifetime,
|
||||
"one_shot": one_shot,
|
||||
"created": true,
|
||||
})
|
||||
|
||||
|
||||
func _set_particle_material(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := _get_particles_node_any(node_path)
|
||||
if node == null:
|
||||
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
|
||||
|
||||
var old_mat: ParticleProcessMaterial = node.get("process_material")
|
||||
var mat: ParticleProcessMaterial
|
||||
if old_mat != null:
|
||||
mat = old_mat.duplicate(true) as ParticleProcessMaterial
|
||||
else:
|
||||
mat = ParticleProcessMaterial.new()
|
||||
|
||||
var changes: Array = []
|
||||
|
||||
# Direction
|
||||
if params.has("direction"):
|
||||
var dir = params["direction"]
|
||||
if dir is Dictionary:
|
||||
mat.direction = Vector3(float(dir.get("x", 0)), float(dir.get("y", 0)), float(dir.get("z", 0)))
|
||||
changes.append("direction")
|
||||
elif dir is String:
|
||||
var expr := Expression.new()
|
||||
if expr.parse(dir) == OK:
|
||||
var parsed = expr.execute()
|
||||
if parsed is Vector3:
|
||||
mat.direction = parsed
|
||||
changes.append("direction")
|
||||
|
||||
# Spread
|
||||
if params.has("spread"):
|
||||
mat.spread = float(params["spread"])
|
||||
changes.append("spread")
|
||||
|
||||
# Initial velocity
|
||||
if params.has("initial_velocity_min"):
|
||||
mat.initial_velocity_min = float(params["initial_velocity_min"])
|
||||
changes.append("initial_velocity_min")
|
||||
if params.has("initial_velocity_max"):
|
||||
mat.initial_velocity_max = float(params["initial_velocity_max"])
|
||||
changes.append("initial_velocity_max")
|
||||
|
||||
# Gravity
|
||||
if params.has("gravity"):
|
||||
var grav = params["gravity"]
|
||||
if grav is Dictionary:
|
||||
mat.gravity = Vector3(float(grav.get("x", 0)), float(grav.get("y", 0)), float(grav.get("z", 0)))
|
||||
changes.append("gravity")
|
||||
elif grav is String:
|
||||
var expr := Expression.new()
|
||||
if expr.parse(grav) == OK:
|
||||
var parsed = expr.execute()
|
||||
if parsed is Vector3:
|
||||
mat.gravity = parsed
|
||||
changes.append("gravity")
|
||||
|
||||
# Scale
|
||||
if params.has("scale_min"):
|
||||
mat.scale_min = float(params["scale_min"])
|
||||
changes.append("scale_min")
|
||||
if params.has("scale_max"):
|
||||
mat.scale_max = float(params["scale_max"])
|
||||
changes.append("scale_max")
|
||||
|
||||
# Color
|
||||
if params.has("color"):
|
||||
mat.color = _parse_color(str(params["color"]))
|
||||
changes.append("color")
|
||||
|
||||
# Emission shape
|
||||
if params.has("emission_shape"):
|
||||
var shape_str: String = str(params["emission_shape"]).to_lower()
|
||||
match shape_str:
|
||||
"point":
|
||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_POINT
|
||||
"sphere":
|
||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
|
||||
if params.has("emission_sphere_radius"):
|
||||
mat.emission_sphere_radius = float(params["emission_sphere_radius"])
|
||||
"sphere_surface":
|
||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE
|
||||
if params.has("emission_sphere_radius"):
|
||||
mat.emission_sphere_radius = float(params["emission_sphere_radius"])
|
||||
"box":
|
||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
if params.has("emission_box_extents"):
|
||||
var ext = params["emission_box_extents"]
|
||||
if ext is Dictionary:
|
||||
mat.emission_box_extents = Vector3(float(ext.get("x", 1)), float(ext.get("y", 1)), float(ext.get("z", 1)))
|
||||
"ring":
|
||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_RING
|
||||
if params.has("emission_ring_radius"):
|
||||
mat.emission_ring_radius = float(params["emission_ring_radius"])
|
||||
if params.has("emission_ring_inner_radius"):
|
||||
mat.emission_ring_inner_radius = float(params["emission_ring_inner_radius"])
|
||||
if params.has("emission_ring_height"):
|
||||
mat.emission_ring_height = float(params["emission_ring_height"])
|
||||
changes.append("emission_shape")
|
||||
|
||||
# Angular velocity
|
||||
if params.has("angular_velocity_min"):
|
||||
mat.angular_velocity_min = float(params["angular_velocity_min"])
|
||||
changes.append("angular_velocity_min")
|
||||
if params.has("angular_velocity_max"):
|
||||
mat.angular_velocity_max = float(params["angular_velocity_max"])
|
||||
changes.append("angular_velocity_max")
|
||||
|
||||
# Orbit velocity
|
||||
if params.has("orbit_velocity_min"):
|
||||
mat.orbit_velocity_min = float(params["orbit_velocity_min"])
|
||||
changes.append("orbit_velocity_min")
|
||||
if params.has("orbit_velocity_max"):
|
||||
mat.orbit_velocity_max = float(params["orbit_velocity_max"])
|
||||
changes.append("orbit_velocity_max")
|
||||
|
||||
# Damping
|
||||
if params.has("damping_min"):
|
||||
mat.damping_min = float(params["damping_min"])
|
||||
changes.append("damping_min")
|
||||
if params.has("damping_max"):
|
||||
mat.damping_max = float(params["damping_max"])
|
||||
changes.append("damping_max")
|
||||
|
||||
# Attractor interaction
|
||||
if params.has("attractor_interaction_enabled"):
|
||||
mat.attractor_interaction_enabled = bool(params["attractor_interaction_enabled"])
|
||||
changes.append("attractor_interaction_enabled")
|
||||
|
||||
if not changes.is_empty():
|
||||
set_property_with_undo(node, "process_material", mat, "MCP: Set particle material")
|
||||
return success({"node_path": node_path, "changes": changes})
|
||||
|
||||
|
||||
func _set_particle_color_gradient(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := _get_particles_node_any(node_path)
|
||||
if node == null:
|
||||
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
|
||||
|
||||
var old_mat: ParticleProcessMaterial = node.get("process_material")
|
||||
var mat: ParticleProcessMaterial
|
||||
if old_mat != null:
|
||||
mat = old_mat.duplicate(true) as ParticleProcessMaterial
|
||||
else:
|
||||
mat = ParticleProcessMaterial.new()
|
||||
|
||||
if not params.has("stops") or not params["stops"] is Array:
|
||||
return error_invalid_params("Missing required parameter: stops (array of {offset, color})")
|
||||
|
||||
var stops: Array = params["stops"]
|
||||
if stops.is_empty():
|
||||
return error_invalid_params("stops array must not be empty")
|
||||
|
||||
var gradient := Gradient.new()
|
||||
# Remove default points
|
||||
while gradient.get_point_count() > 0:
|
||||
gradient.remove_point(0)
|
||||
|
||||
for stop in stops:
|
||||
if stop is Dictionary:
|
||||
var offset: float = float(stop.get("offset", 0.0))
|
||||
var color: Color = _parse_color(str(stop.get("color", "#ffffff")))
|
||||
gradient.add_point(offset, color)
|
||||
|
||||
var grad_tex := GradientTexture1D.new()
|
||||
grad_tex.gradient = gradient
|
||||
mat.color_ramp = grad_tex
|
||||
set_property_with_undo(node, "process_material", mat, "MCP: Set particle color gradient")
|
||||
|
||||
return success({"node_path": node_path, "stops_count": gradient.get_point_count()})
|
||||
|
||||
|
||||
func _apply_particle_preset(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "preset")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var preset: String = result2[0].to_lower()
|
||||
|
||||
var node := _get_particles_node_any(node_path)
|
||||
if node == null:
|
||||
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
|
||||
|
||||
var old_state := _capture_particle_state(node)
|
||||
var preset_state := {}
|
||||
var mat := ParticleProcessMaterial.new()
|
||||
var is_2d: bool = node is GPUParticles2D
|
||||
|
||||
# Default gravity for 2D (Y-down) vs 3D (Y-down)
|
||||
var gravity_down := Vector3(0, 98.0 if is_2d else 9.8, 0)
|
||||
var _gravity_up := Vector3(0, -98.0 if is_2d else -9.8, 0)
|
||||
var gravity_none := Vector3.ZERO
|
||||
|
||||
match preset:
|
||||
"explosion":
|
||||
preset_state["amount"] = 32
|
||||
preset_state["lifetime"] = 0.6
|
||||
preset_state["one_shot"] = true
|
||||
preset_state["explosiveness"] = 1.0
|
||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
||||
mat.spread = 180.0
|
||||
mat.initial_velocity_min = 100.0 if is_2d else 5.0
|
||||
mat.initial_velocity_max = 200.0 if is_2d else 10.0
|
||||
mat.gravity = gravity_down * 0.5
|
||||
mat.damping_min = 2.0
|
||||
mat.damping_max = 4.0
|
||||
mat.scale_min = 0.5
|
||||
mat.scale_max = 1.5
|
||||
mat.color = Color(1.0, 0.6, 0.1)
|
||||
_apply_gradient(mat, [
|
||||
{"offset": 0.0, "color": Color.WHITE},
|
||||
{"offset": 0.3, "color": Color(1.0, 0.8, 0.2)},
|
||||
{"offset": 0.7, "color": Color(1.0, 0.3, 0.0)},
|
||||
{"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)},
|
||||
])
|
||||
|
||||
"fire":
|
||||
preset_state["amount"] = 24
|
||||
preset_state["lifetime"] = 1.2
|
||||
preset_state["one_shot"] = false
|
||||
preset_state["explosiveness"] = 0.0
|
||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
||||
mat.spread = 15.0
|
||||
mat.initial_velocity_min = 30.0 if is_2d else 1.5
|
||||
mat.initial_velocity_max = 60.0 if is_2d else 3.0
|
||||
mat.gravity = gravity_none
|
||||
mat.scale_min = 0.8
|
||||
mat.scale_max = 1.5
|
||||
_apply_gradient(mat, [
|
||||
{"offset": 0.0, "color": Color(1.0, 1.0, 0.5)},
|
||||
{"offset": 0.3, "color": Color(1.0, 0.6, 0.0)},
|
||||
{"offset": 0.7, "color": Color(0.8, 0.2, 0.0)},
|
||||
{"offset": 1.0, "color": Color(0.2, 0.0, 0.0, 0.0)},
|
||||
])
|
||||
|
||||
"smoke":
|
||||
preset_state["amount"] = 16
|
||||
preset_state["lifetime"] = 3.0
|
||||
preset_state["one_shot"] = false
|
||||
preset_state["explosiveness"] = 0.0
|
||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
||||
mat.spread = 25.0
|
||||
mat.initial_velocity_min = 10.0 if is_2d else 0.5
|
||||
mat.initial_velocity_max = 25.0 if is_2d else 1.2
|
||||
mat.gravity = gravity_none
|
||||
mat.scale_min = 1.5
|
||||
mat.scale_max = 3.0
|
||||
mat.damping_min = 1.0
|
||||
mat.damping_max = 2.0
|
||||
_apply_gradient(mat, [
|
||||
{"offset": 0.0, "color": Color(0.5, 0.5, 0.5, 0.6)},
|
||||
{"offset": 0.5, "color": Color(0.6, 0.6, 0.6, 0.3)},
|
||||
{"offset": 1.0, "color": Color(0.7, 0.7, 0.7, 0.0)},
|
||||
])
|
||||
|
||||
"sparks":
|
||||
preset_state["amount"] = 48
|
||||
preset_state["lifetime"] = 0.4
|
||||
preset_state["one_shot"] = true
|
||||
preset_state["explosiveness"] = 0.95
|
||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
||||
mat.spread = 180.0
|
||||
mat.initial_velocity_min = 200.0 if is_2d else 8.0
|
||||
mat.initial_velocity_max = 400.0 if is_2d else 16.0
|
||||
mat.gravity = gravity_down
|
||||
mat.scale_min = 0.1
|
||||
mat.scale_max = 0.3
|
||||
mat.damping_min = 1.0
|
||||
mat.damping_max = 3.0
|
||||
_apply_gradient(mat, [
|
||||
{"offset": 0.0, "color": Color(1.0, 1.0, 0.8)},
|
||||
{"offset": 0.5, "color": Color(1.0, 0.7, 0.2)},
|
||||
{"offset": 1.0, "color": Color(1.0, 0.3, 0.0, 0.0)},
|
||||
])
|
||||
|
||||
"rain":
|
||||
preset_state["amount"] = 64
|
||||
preset_state["lifetime"] = 0.8
|
||||
preset_state["one_shot"] = false
|
||||
preset_state["explosiveness"] = 0.0
|
||||
mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0)
|
||||
mat.spread = 5.0
|
||||
mat.initial_velocity_min = 300.0 if is_2d else 12.0
|
||||
mat.initial_velocity_max = 400.0 if is_2d else 16.0
|
||||
mat.gravity = gravity_down
|
||||
mat.scale_min = 0.1
|
||||
mat.scale_max = 0.2
|
||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5)
|
||||
mat.color = Color(0.6, 0.7, 1.0, 0.7)
|
||||
|
||||
"snow":
|
||||
preset_state["amount"] = 48
|
||||
preset_state["lifetime"] = 4.0
|
||||
preset_state["one_shot"] = false
|
||||
preset_state["explosiveness"] = 0.0
|
||||
mat.direction = Vector3(0, 1, 0) if is_2d else Vector3(0, -1, 0)
|
||||
mat.spread = 20.0
|
||||
mat.initial_velocity_min = 20.0 if is_2d else 0.8
|
||||
mat.initial_velocity_max = 40.0 if is_2d else 1.5
|
||||
mat.gravity = Vector3(0, 20, 0) if is_2d else Vector3(0, -0.5, 0)
|
||||
mat.scale_min = 0.3
|
||||
mat.scale_max = 0.8
|
||||
mat.angular_velocity_min = -45.0
|
||||
mat.angular_velocity_max = 45.0
|
||||
mat.damping_min = 0.5
|
||||
mat.damping_max = 1.5
|
||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
mat.emission_box_extents = Vector3(200, 0, 0) if is_2d else Vector3(5, 0, 5)
|
||||
mat.color = Color(1.0, 1.0, 1.0, 0.9)
|
||||
|
||||
"magic":
|
||||
preset_state["amount"] = 24
|
||||
preset_state["lifetime"] = 2.0
|
||||
preset_state["one_shot"] = false
|
||||
preset_state["explosiveness"] = 0.0
|
||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
||||
mat.spread = 180.0
|
||||
mat.initial_velocity_min = 20.0 if is_2d else 1.0
|
||||
mat.initial_velocity_max = 50.0 if is_2d else 2.5
|
||||
mat.gravity = gravity_none
|
||||
mat.orbit_velocity_min = 0.5
|
||||
mat.orbit_velocity_max = 1.5
|
||||
mat.scale_min = 0.3
|
||||
mat.scale_max = 0.8
|
||||
mat.damping_min = 1.0
|
||||
mat.damping_max = 2.0
|
||||
_apply_gradient(mat, [
|
||||
{"offset": 0.0, "color": Color(0.3, 0.5, 1.0)},
|
||||
{"offset": 0.25, "color": Color(1.0, 0.3, 0.8)},
|
||||
{"offset": 0.5, "color": Color(0.3, 1.0, 0.5)},
|
||||
{"offset": 0.75, "color": Color(1.0, 0.8, 0.2)},
|
||||
{"offset": 1.0, "color": Color(0.5, 0.3, 1.0, 0.0)},
|
||||
])
|
||||
|
||||
"dust":
|
||||
preset_state["amount"] = 12
|
||||
preset_state["lifetime"] = 5.0
|
||||
preset_state["one_shot"] = false
|
||||
preset_state["explosiveness"] = 0.0
|
||||
mat.direction = Vector3(0, -1, 0) if is_2d else Vector3(0, 1, 0)
|
||||
mat.spread = 180.0
|
||||
mat.initial_velocity_min = 3.0 if is_2d else 0.1
|
||||
mat.initial_velocity_max = 8.0 if is_2d else 0.3
|
||||
mat.gravity = gravity_none
|
||||
mat.scale_min = 0.2
|
||||
mat.scale_max = 0.5
|
||||
mat.damping_min = 0.5
|
||||
mat.damping_max = 1.0
|
||||
mat.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_BOX
|
||||
mat.emission_box_extents = Vector3(100, 100, 0) if is_2d else Vector3(3, 3, 3)
|
||||
_apply_gradient(mat, [
|
||||
{"offset": 0.0, "color": Color(0.8, 0.75, 0.65, 0.0)},
|
||||
{"offset": 0.2, "color": Color(0.8, 0.75, 0.65, 0.3)},
|
||||
{"offset": 0.8, "color": Color(0.8, 0.75, 0.65, 0.3)},
|
||||
{"offset": 1.0, "color": Color(0.8, 0.75, 0.65, 0.0)},
|
||||
])
|
||||
|
||||
_:
|
||||
return error_invalid_params("Unknown preset: '%s'. Valid presets: explosion, fire, smoke, sparks, rain, snow, magic, dust" % preset)
|
||||
|
||||
preset_state["process_material"] = mat
|
||||
_register_particle_state_undo(node, old_state, preset_state, "MCP: Apply particle preset")
|
||||
|
||||
return success({"node_path": node_path, "preset": preset, "applied": true})
|
||||
|
||||
|
||||
func _capture_particle_state(node: Node) -> Dictionary:
|
||||
var state := {}
|
||||
for property: String in ["amount", "lifetime", "one_shot", "explosiveness", "randomness", "emitting", "process_material"]:
|
||||
if property in node:
|
||||
state[property] = node.get(property)
|
||||
return state
|
||||
|
||||
|
||||
func _register_particle_state_undo(node: Node, old_state: Dictionary, new_state: Dictionary, action_name: String) -> void:
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action(action_name)
|
||||
for property: String in new_state:
|
||||
undo_redo.add_do_property(node, property, new_state[property])
|
||||
if new_state[property] is Resource:
|
||||
undo_redo.add_do_reference(new_state[property])
|
||||
undo_redo.add_undo_property(node, property, old_state.get(property, null))
|
||||
if old_state.get(property, null) is Resource:
|
||||
undo_redo.add_undo_reference(old_state[property])
|
||||
undo_redo.commit_action()
|
||||
|
||||
|
||||
func _apply_gradient(mat: ParticleProcessMaterial, stops: Array) -> void:
|
||||
var gradient := Gradient.new()
|
||||
# Remove default points before adding custom stops
|
||||
for i in range(gradient.get_point_count() - 1, -1, -1):
|
||||
gradient.remove_point(i)
|
||||
for stop in stops:
|
||||
gradient.add_point(stop["offset"], stop["color"])
|
||||
var grad_tex := GradientTexture1D.new()
|
||||
grad_tex.width = 64 # Smaller texture to avoid GPU issues in compatibility mode
|
||||
grad_tex.gradient = gradient
|
||||
# Defer color_ramp assignment to avoid editor crash during rendering
|
||||
mat.set_deferred("color_ramp", grad_tex)
|
||||
|
||||
|
||||
func _get_particle_info(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := _get_particles_node_any(node_path)
|
||||
if node == null:
|
||||
return error_not_found("GPUParticles2D/3D at '%s'" % node_path)
|
||||
|
||||
var info: Dictionary = {
|
||||
"node_path": node_path,
|
||||
"type": node.get_class(),
|
||||
"amount": node.get("amount"),
|
||||
"lifetime": node.get("lifetime"),
|
||||
"one_shot": node.get("one_shot"),
|
||||
"explosiveness": node.get("explosiveness"),
|
||||
"randomness": node.get("randomness"),
|
||||
"emitting": node.get("emitting"),
|
||||
}
|
||||
|
||||
var mat: ParticleProcessMaterial = node.get("process_material")
|
||||
if mat != null and mat is ParticleProcessMaterial:
|
||||
var mat_info: Dictionary = {
|
||||
"direction": str(mat.direction),
|
||||
"spread": mat.spread,
|
||||
"initial_velocity_min": mat.initial_velocity_min,
|
||||
"initial_velocity_max": mat.initial_velocity_max,
|
||||
"gravity": str(mat.gravity),
|
||||
"scale_min": mat.scale_min,
|
||||
"scale_max": mat.scale_max,
|
||||
"color": str(mat.color),
|
||||
"angular_velocity_min": mat.angular_velocity_min,
|
||||
"angular_velocity_max": mat.angular_velocity_max,
|
||||
"orbit_velocity_min": mat.orbit_velocity_min,
|
||||
"orbit_velocity_max": mat.orbit_velocity_max,
|
||||
"damping_min": mat.damping_min,
|
||||
"damping_max": mat.damping_max,
|
||||
"attractor_interaction_enabled": mat.attractor_interaction_enabled,
|
||||
}
|
||||
|
||||
# Emission shape
|
||||
var shape_name: String
|
||||
match mat.emission_shape:
|
||||
ParticleProcessMaterial.EMISSION_SHAPE_POINT: shape_name = "point"
|
||||
ParticleProcessMaterial.EMISSION_SHAPE_SPHERE: shape_name = "sphere"
|
||||
ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE: shape_name = "sphere_surface"
|
||||
ParticleProcessMaterial.EMISSION_SHAPE_BOX: shape_name = "box"
|
||||
ParticleProcessMaterial.EMISSION_SHAPE_RING: shape_name = "ring"
|
||||
_: shape_name = "unknown(%d)" % mat.emission_shape
|
||||
|
||||
mat_info["emission_shape"] = shape_name
|
||||
|
||||
match mat.emission_shape:
|
||||
ParticleProcessMaterial.EMISSION_SHAPE_SPHERE, ParticleProcessMaterial.EMISSION_SHAPE_SPHERE_SURFACE:
|
||||
mat_info["emission_sphere_radius"] = mat.emission_sphere_radius
|
||||
ParticleProcessMaterial.EMISSION_SHAPE_BOX:
|
||||
mat_info["emission_box_extents"] = str(mat.emission_box_extents)
|
||||
ParticleProcessMaterial.EMISSION_SHAPE_RING:
|
||||
mat_info["emission_ring_radius"] = mat.emission_ring_radius
|
||||
mat_info["emission_ring_inner_radius"] = mat.emission_ring_inner_radius
|
||||
mat_info["emission_ring_height"] = mat.emission_ring_height
|
||||
|
||||
# Color gradient
|
||||
if mat.color_ramp != null and mat.color_ramp is GradientTexture1D:
|
||||
var grad_tex: GradientTexture1D = mat.color_ramp
|
||||
if grad_tex.gradient != null:
|
||||
var gradient_stops: Array = []
|
||||
var grad: Gradient = grad_tex.gradient
|
||||
for i in grad.get_point_count():
|
||||
gradient_stops.append({
|
||||
"offset": grad.get_offset(i),
|
||||
"color": str(grad.get_color(i)),
|
||||
})
|
||||
mat_info["color_ramp"] = gradient_stops
|
||||
|
||||
info["material"] = mat_info
|
||||
else:
|
||||
info["material"] = null
|
||||
|
||||
return success(info)
|
||||
@@ -0,0 +1,757 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"setup_collision": _setup_collision,
|
||||
"set_physics_layers": _set_physics_layers,
|
||||
"get_physics_layers": _get_physics_layers,
|
||||
"add_raycast": _add_raycast,
|
||||
"setup_physics_body": _setup_physics_body,
|
||||
"get_collision_info": _get_collision_info,
|
||||
}
|
||||
|
||||
|
||||
## Determine if a node (or its ancestors) lives in a 2D or 3D context.
|
||||
## Returns "2d", "3d", or "" if undetermined.
|
||||
func _detect_dimension(node: Node) -> String:
|
||||
if node is Node2D or node is Control:
|
||||
return "2d"
|
||||
if node is Node3D:
|
||||
return "3d"
|
||||
# Walk up the tree
|
||||
var parent := node.get_parent()
|
||||
while parent != null:
|
||||
if parent is Node2D or parent is Control:
|
||||
return "2d"
|
||||
if parent is Node3D:
|
||||
return "3d"
|
||||
parent = parent.get_parent()
|
||||
return ""
|
||||
|
||||
|
||||
func _setup_collision(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "shape")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var shape_name: String = result2[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
var dim := _detect_dimension(node)
|
||||
if dim.is_empty():
|
||||
# Allow explicit override
|
||||
dim = optional_string(params, "dimension", "2d")
|
||||
|
||||
# Validate parent can have collision children
|
||||
var valid_parents_2d := ["PhysicsBody2D", "Area2D", "StaticBody2D", "CharacterBody2D", "RigidBody2D", "AnimatableBody2D"]
|
||||
var valid_parents_3d := ["PhysicsBody3D", "Area3D", "StaticBody3D", "CharacterBody3D", "RigidBody3D", "AnimatableBody3D"]
|
||||
|
||||
var is_valid_parent := false
|
||||
if dim == "2d":
|
||||
for vp: String in valid_parents_2d:
|
||||
if node.is_class(vp):
|
||||
is_valid_parent = true
|
||||
break
|
||||
else:
|
||||
for vp: String in valid_parents_3d:
|
||||
if node.is_class(vp):
|
||||
is_valid_parent = true
|
||||
break
|
||||
|
||||
if not is_valid_parent:
|
||||
return error_invalid_params("Node '%s' (%s) is not a physics body or area. CollisionShape should be added to a PhysicsBody or Area node." % [node_path, node.get_class()])
|
||||
|
||||
# Create shape resource
|
||||
var shape: Resource = null
|
||||
var child_name := "CollisionShape"
|
||||
|
||||
if dim == "2d":
|
||||
match shape_name:
|
||||
"rectangle", "rect":
|
||||
shape = RectangleShape2D.new()
|
||||
var w: float = float(params.get("width", 32.0))
|
||||
var h: float = float(params.get("height", 32.0))
|
||||
shape.size = Vector2(w, h)
|
||||
"circle":
|
||||
shape = CircleShape2D.new()
|
||||
shape.radius = float(params.get("radius", 16.0))
|
||||
"capsule":
|
||||
shape = CapsuleShape2D.new()
|
||||
shape.radius = float(params.get("radius", 16.0))
|
||||
shape.height = float(params.get("height", 40.0))
|
||||
"segment":
|
||||
shape = SegmentShape2D.new()
|
||||
shape.a = Vector2(float(params.get("ax", 0.0)), float(params.get("ay", 0.0)))
|
||||
shape.b = Vector2(float(params.get("bx", 32.0)), float(params.get("by", 0.0)))
|
||||
"custom":
|
||||
# ConvexPolygonShape2D — expects "points" as array of [x,y] pairs
|
||||
shape = ConvexPolygonShape2D.new()
|
||||
var points_data: Array = params.get("points", [])
|
||||
var pool: PackedVector2Array = PackedVector2Array()
|
||||
for p: Variant in points_data:
|
||||
if p is Array and p.size() >= 2:
|
||||
pool.append(Vector2(float(p[0]), float(p[1])))
|
||||
if pool.size() >= 3:
|
||||
shape.points = pool
|
||||
_:
|
||||
return error_invalid_params("Unknown 2D shape: '%s'. Available: rectangle, circle, capsule, segment, custom" % shape_name)
|
||||
|
||||
var collision_node := CollisionShape2D.new()
|
||||
collision_node.shape = shape
|
||||
collision_node.name = child_name
|
||||
|
||||
var disabled: bool = optional_bool(params, "disabled", false)
|
||||
collision_node.disabled = disabled
|
||||
var one_way: bool = optional_bool(params, "one_way_collision", false)
|
||||
collision_node.one_way_collision = one_way
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Add CollisionShape2D to %s" % node.name)
|
||||
undo_redo.add_do_method(node, "add_child", collision_node)
|
||||
undo_redo.add_do_method(collision_node, "set_owner", root)
|
||||
undo_redo.add_do_reference(collision_node)
|
||||
undo_redo.add_undo_method(node, "remove_child", collision_node)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(collision_node)),
|
||||
"shape_type": shape.get_class(),
|
||||
"dimension": "2D",
|
||||
})
|
||||
|
||||
else:
|
||||
# 3D shapes
|
||||
match shape_name:
|
||||
"box", "rectangle", "rect":
|
||||
shape = BoxShape3D.new()
|
||||
var sx: float = float(params.get("width", 1.0))
|
||||
var sy: float = float(params.get("height", 1.0))
|
||||
var sz: float = float(params.get("depth", 1.0))
|
||||
shape.size = Vector3(sx, sy, sz)
|
||||
"sphere", "circle":
|
||||
shape = SphereShape3D.new()
|
||||
shape.radius = float(params.get("radius", 0.5))
|
||||
"capsule":
|
||||
shape = CapsuleShape3D.new()
|
||||
shape.radius = float(params.get("radius", 0.5))
|
||||
shape.height = float(params.get("height", 2.0))
|
||||
"cylinder":
|
||||
shape = CylinderShape3D.new()
|
||||
shape.radius = float(params.get("radius", 0.5))
|
||||
shape.height = float(params.get("height", 2.0))
|
||||
"convex", "custom":
|
||||
shape = ConvexPolygonShape3D.new()
|
||||
var points_data: Array = params.get("points", [])
|
||||
var pool: PackedVector3Array = PackedVector3Array()
|
||||
for p: Variant in points_data:
|
||||
if p is Array and p.size() >= 3:
|
||||
pool.append(Vector3(float(p[0]), float(p[1]), float(p[2])))
|
||||
if pool.size() >= 4:
|
||||
shape.points = pool
|
||||
_:
|
||||
return error_invalid_params("Unknown 3D shape: '%s'. Available: box, sphere, capsule, cylinder, convex" % shape_name)
|
||||
|
||||
var collision_node := CollisionShape3D.new()
|
||||
collision_node.shape = shape
|
||||
collision_node.name = child_name
|
||||
|
||||
var disabled: bool = optional_bool(params, "disabled", false)
|
||||
collision_node.disabled = disabled
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Add CollisionShape3D to %s" % node.name)
|
||||
undo_redo.add_do_method(node, "add_child", collision_node)
|
||||
undo_redo.add_do_method(collision_node, "set_owner", root)
|
||||
undo_redo.add_do_reference(collision_node)
|
||||
undo_redo.add_undo_method(node, "remove_child", collision_node)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(collision_node)),
|
||||
"shape_type": shape.get_class(),
|
||||
"dimension": "3D",
|
||||
})
|
||||
|
||||
|
||||
func _get_layer_name(dim: String, layer_index: int) -> String:
|
||||
var setting_key := "layer_names/%s_physics/layer_%d" % [dim, layer_index]
|
||||
if ProjectSettings.has_setting(setting_key):
|
||||
var name_val: Variant = ProjectSettings.get_setting(setting_key)
|
||||
if name_val is String and not (name_val as String).is_empty():
|
||||
return name_val as String
|
||||
return ""
|
||||
|
||||
|
||||
func _layer_bitmask_to_info(bitmask: int, dim: String) -> Array:
|
||||
var layers: Array = []
|
||||
for i in range(1, 33):
|
||||
if bitmask & (1 << (i - 1)):
|
||||
var layer_name := _get_layer_name(dim, i)
|
||||
var entry: Dictionary = {"layer": i}
|
||||
if not layer_name.is_empty():
|
||||
entry["name"] = layer_name
|
||||
layers.append(entry)
|
||||
return layers
|
||||
|
||||
|
||||
func _set_physics_layers(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
# Check node has collision_layer/collision_mask properties
|
||||
if not "collision_layer" in node:
|
||||
return error_invalid_params("Node '%s' (%s) does not have collision_layer property" % [node_path, node.get_class()])
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set physics layers on %s" % node.name)
|
||||
|
||||
var changes: Dictionary = {}
|
||||
|
||||
if params.has("collision_layer"):
|
||||
var old_layer: int = node.get("collision_layer")
|
||||
var new_layer: int = _parse_layer_value(params["collision_layer"])
|
||||
undo_redo.add_do_property(node, "collision_layer", new_layer)
|
||||
undo_redo.add_undo_property(node, "collision_layer", old_layer)
|
||||
changes["collision_layer"] = new_layer
|
||||
|
||||
if params.has("collision_mask"):
|
||||
var old_mask: int = node.get("collision_mask")
|
||||
var new_mask: int = _parse_layer_value(params["collision_mask"])
|
||||
undo_redo.add_do_property(node, "collision_mask", new_mask)
|
||||
undo_redo.add_undo_property(node, "collision_mask", old_mask)
|
||||
changes["collision_mask"] = new_mask
|
||||
|
||||
if changes.is_empty():
|
||||
return error_invalid_params("Must provide collision_layer and/or collision_mask")
|
||||
|
||||
undo_redo.commit_action()
|
||||
|
||||
var dim := _detect_dimension(node)
|
||||
if dim.is_empty():
|
||||
dim = "2d"
|
||||
|
||||
var result_data: Dictionary = {
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
}
|
||||
if changes.has("collision_layer"):
|
||||
result_data["collision_layer"] = changes["collision_layer"]
|
||||
result_data["collision_layer_info"] = _layer_bitmask_to_info(changes["collision_layer"], dim)
|
||||
if changes.has("collision_mask"):
|
||||
result_data["collision_mask"] = changes["collision_mask"]
|
||||
result_data["collision_mask_info"] = _layer_bitmask_to_info(changes["collision_mask"], dim)
|
||||
|
||||
return success(result_data)
|
||||
|
||||
|
||||
## Parse layer value: can be an int bitmask, or an array of layer numbers [1, 3, 5]
|
||||
func _parse_layer_value(value: Variant) -> int:
|
||||
if value is int or value is float:
|
||||
return int(value)
|
||||
if value is Array:
|
||||
var bitmask: int = 0
|
||||
for layer_num: Variant in value:
|
||||
var n: int = int(layer_num)
|
||||
if n >= 1 and n <= 32:
|
||||
bitmask |= (1 << (n - 1))
|
||||
return bitmask
|
||||
# Try parsing as int
|
||||
return int(value)
|
||||
|
||||
|
||||
func _get_physics_layers(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
if not "collision_layer" in node:
|
||||
return error_invalid_params("Node '%s' (%s) does not have collision_layer property" % [node_path, node.get_class()])
|
||||
|
||||
var layer: int = node.get("collision_layer")
|
||||
var mask: int = node.get("collision_mask")
|
||||
|
||||
var dim := _detect_dimension(node)
|
||||
if dim.is_empty():
|
||||
dim = "2d"
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"type": node.get_class(),
|
||||
"collision_layer": layer,
|
||||
"collision_layer_info": _layer_bitmask_to_info(layer, dim),
|
||||
"collision_mask": mask,
|
||||
"collision_mask_info": _layer_bitmask_to_info(mask, dim),
|
||||
})
|
||||
|
||||
|
||||
func _add_raycast(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
var dim := _detect_dimension(node)
|
||||
if dim.is_empty():
|
||||
dim = optional_string(params, "dimension", "2d")
|
||||
|
||||
var ray_name: String = optional_string(params, "name", "RayCast")
|
||||
var enabled: bool = optional_bool(params, "enabled", true)
|
||||
var collision_mask: int = optional_int(params, "collision_mask", 1)
|
||||
var collide_with_areas: bool = optional_bool(params, "collide_with_areas", false)
|
||||
var collide_with_bodies: bool = optional_bool(params, "collide_with_bodies", true)
|
||||
var hit_from_inside: bool = optional_bool(params, "hit_from_inside", false)
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
|
||||
if dim == "2d":
|
||||
var ray := RayCast2D.new()
|
||||
ray.name = ray_name
|
||||
ray.enabled = enabled
|
||||
ray.collision_mask = collision_mask
|
||||
ray.collide_with_areas = collide_with_areas
|
||||
ray.collide_with_bodies = collide_with_bodies
|
||||
ray.hit_from_inside = hit_from_inside
|
||||
|
||||
var tx: float = float(params.get("target_x", 0.0))
|
||||
var ty: float = float(params.get("target_y", 50.0))
|
||||
ray.target_position = Vector2(tx, ty)
|
||||
|
||||
undo_redo.create_action("MCP: Add RayCast2D to %s" % node.name)
|
||||
undo_redo.add_do_method(node, "add_child", ray)
|
||||
undo_redo.add_do_method(ray, "set_owner", root)
|
||||
undo_redo.add_do_reference(ray)
|
||||
undo_redo.add_undo_method(node, "remove_child", ray)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(ray)),
|
||||
"type": "RayCast2D",
|
||||
"target_position": "Vector2(%s, %s)" % [tx, ty],
|
||||
"collision_mask": collision_mask,
|
||||
})
|
||||
|
||||
else:
|
||||
var ray := RayCast3D.new()
|
||||
ray.name = ray_name
|
||||
ray.enabled = enabled
|
||||
ray.collision_mask = collision_mask
|
||||
ray.collide_with_areas = collide_with_areas
|
||||
ray.collide_with_bodies = collide_with_bodies
|
||||
ray.hit_from_inside = hit_from_inside
|
||||
|
||||
var tx: float = float(params.get("target_x", 0.0))
|
||||
var ty: float = float(params.get("target_y", -1.0))
|
||||
var tz: float = float(params.get("target_z", 0.0))
|
||||
ray.target_position = Vector3(tx, ty, tz)
|
||||
|
||||
undo_redo.create_action("MCP: Add RayCast3D to %s" % node.name)
|
||||
undo_redo.add_do_method(node, "add_child", ray)
|
||||
undo_redo.add_do_method(ray, "set_owner", root)
|
||||
undo_redo.add_do_reference(ray)
|
||||
undo_redo.add_undo_method(node, "remove_child", ray)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(ray)),
|
||||
"type": "RayCast3D",
|
||||
"target_position": "Vector3(%s, %s, %s)" % [tx, ty, tz],
|
||||
"collision_mask": collision_mask,
|
||||
})
|
||||
|
||||
|
||||
func _setup_physics_body(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Setup physics body %s" % node.name)
|
||||
|
||||
var applied: Dictionary = {}
|
||||
|
||||
if node is CharacterBody2D or node is CharacterBody3D:
|
||||
# CharacterBody properties
|
||||
if params.has("floor_stop_on_slope"):
|
||||
var old_val: bool = node.floor_stop_on_slope
|
||||
var new_val: bool = bool(params["floor_stop_on_slope"])
|
||||
undo_redo.add_do_property(node, "floor_stop_on_slope", new_val)
|
||||
undo_redo.add_undo_property(node, "floor_stop_on_slope", old_val)
|
||||
applied["floor_stop_on_slope"] = new_val
|
||||
|
||||
if params.has("floor_max_angle"):
|
||||
var old_val: float = node.floor_max_angle
|
||||
var new_val: float = float(params["floor_max_angle"])
|
||||
undo_redo.add_do_property(node, "floor_max_angle", new_val)
|
||||
undo_redo.add_undo_property(node, "floor_max_angle", old_val)
|
||||
applied["floor_max_angle"] = new_val
|
||||
|
||||
if params.has("floor_snap_length"):
|
||||
var old_val: float = node.floor_snap_length
|
||||
var new_val: float = float(params["floor_snap_length"])
|
||||
undo_redo.add_do_property(node, "floor_snap_length", new_val)
|
||||
undo_redo.add_undo_property(node, "floor_snap_length", old_val)
|
||||
applied["floor_snap_length"] = new_val
|
||||
|
||||
if params.has("wall_min_slide_angle"):
|
||||
var old_val: float = node.wall_min_slide_angle
|
||||
var new_val: float = float(params["wall_min_slide_angle"])
|
||||
undo_redo.add_do_property(node, "wall_min_slide_angle", new_val)
|
||||
undo_redo.add_undo_property(node, "wall_min_slide_angle", old_val)
|
||||
applied["wall_min_slide_angle"] = new_val
|
||||
|
||||
if params.has("motion_mode"):
|
||||
var mode_str: String = str(params["motion_mode"])
|
||||
var mode_val: int = 0
|
||||
if node is CharacterBody2D:
|
||||
match mode_str.to_lower():
|
||||
"grounded":
|
||||
mode_val = CharacterBody2D.MOTION_MODE_GROUNDED
|
||||
"floating":
|
||||
mode_val = CharacterBody2D.MOTION_MODE_FLOATING
|
||||
_:
|
||||
mode_val = int(params["motion_mode"])
|
||||
else:
|
||||
match mode_str.to_lower():
|
||||
"grounded":
|
||||
mode_val = CharacterBody3D.MOTION_MODE_GROUNDED
|
||||
"floating":
|
||||
mode_val = CharacterBody3D.MOTION_MODE_FLOATING
|
||||
_:
|
||||
mode_val = int(params["motion_mode"])
|
||||
var old_val: int = node.motion_mode
|
||||
undo_redo.add_do_property(node, "motion_mode", mode_val)
|
||||
undo_redo.add_undo_property(node, "motion_mode", old_val)
|
||||
applied["motion_mode"] = mode_str
|
||||
|
||||
if params.has("max_slides"):
|
||||
var old_val: int = node.max_slides
|
||||
var new_val: int = int(params["max_slides"])
|
||||
undo_redo.add_do_property(node, "max_slides", new_val)
|
||||
undo_redo.add_undo_property(node, "max_slides", old_val)
|
||||
applied["max_slides"] = new_val
|
||||
|
||||
if params.has("slide_on_ceiling"):
|
||||
var old_val: bool = node.slide_on_ceiling
|
||||
var new_val: bool = bool(params["slide_on_ceiling"])
|
||||
undo_redo.add_do_property(node, "slide_on_ceiling", new_val)
|
||||
undo_redo.add_undo_property(node, "slide_on_ceiling", old_val)
|
||||
applied["slide_on_ceiling"] = new_val
|
||||
|
||||
elif node is RigidBody2D or node is RigidBody3D:
|
||||
# RigidBody properties
|
||||
if params.has("mass"):
|
||||
var old_val: float = node.mass
|
||||
var new_val: float = float(params["mass"])
|
||||
undo_redo.add_do_property(node, "mass", new_val)
|
||||
undo_redo.add_undo_property(node, "mass", old_val)
|
||||
applied["mass"] = new_val
|
||||
|
||||
if params.has("gravity_scale"):
|
||||
var old_val: float = node.gravity_scale
|
||||
var new_val: float = float(params["gravity_scale"])
|
||||
undo_redo.add_do_property(node, "gravity_scale", new_val)
|
||||
undo_redo.add_undo_property(node, "gravity_scale", old_val)
|
||||
applied["gravity_scale"] = new_val
|
||||
|
||||
if params.has("linear_damp"):
|
||||
var old_val: float = node.linear_damp
|
||||
var new_val: float = float(params["linear_damp"])
|
||||
undo_redo.add_do_property(node, "linear_damp", new_val)
|
||||
undo_redo.add_undo_property(node, "linear_damp", old_val)
|
||||
applied["linear_damp"] = new_val
|
||||
|
||||
if params.has("angular_damp"):
|
||||
var old_val: float = node.angular_damp
|
||||
var new_val: float = float(params["angular_damp"])
|
||||
undo_redo.add_do_property(node, "angular_damp", new_val)
|
||||
undo_redo.add_undo_property(node, "angular_damp", old_val)
|
||||
applied["angular_damp"] = new_val
|
||||
|
||||
if params.has("freeze"):
|
||||
var old_val: bool = node.freeze
|
||||
var new_val: bool = bool(params["freeze"])
|
||||
undo_redo.add_do_property(node, "freeze", new_val)
|
||||
undo_redo.add_undo_property(node, "freeze", old_val)
|
||||
applied["freeze"] = new_val
|
||||
|
||||
if params.has("freeze_mode"):
|
||||
var mode_str: String = str(params["freeze_mode"])
|
||||
var mode_val: int = 0
|
||||
if node is RigidBody2D:
|
||||
match mode_str.to_lower():
|
||||
"static":
|
||||
mode_val = RigidBody2D.FREEZE_MODE_STATIC
|
||||
"kinematic":
|
||||
mode_val = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
_:
|
||||
mode_val = int(params["freeze_mode"])
|
||||
else:
|
||||
match mode_str.to_lower():
|
||||
"static":
|
||||
mode_val = RigidBody3D.FREEZE_MODE_STATIC
|
||||
"kinematic":
|
||||
mode_val = RigidBody3D.FREEZE_MODE_KINEMATIC
|
||||
_:
|
||||
mode_val = int(params["freeze_mode"])
|
||||
var old_val: int = node.freeze_mode
|
||||
undo_redo.add_do_property(node, "freeze_mode", mode_val)
|
||||
undo_redo.add_undo_property(node, "freeze_mode", old_val)
|
||||
applied["freeze_mode"] = mode_str
|
||||
|
||||
if params.has("continuous_cd"):
|
||||
if node is RigidBody2D:
|
||||
var ccd_str: String = str(params["continuous_cd"])
|
||||
var ccd_val: int = 0
|
||||
match ccd_str.to_lower():
|
||||
"disabled":
|
||||
ccd_val = RigidBody2D.CCD_MODE_DISABLED
|
||||
"cast_ray":
|
||||
ccd_val = RigidBody2D.CCD_MODE_CAST_RAY
|
||||
"cast_shape":
|
||||
ccd_val = RigidBody2D.CCD_MODE_CAST_SHAPE
|
||||
_:
|
||||
ccd_val = int(params["continuous_cd"])
|
||||
var old_val: int = node.continuous_cd
|
||||
undo_redo.add_do_property(node, "continuous_cd", ccd_val)
|
||||
undo_redo.add_undo_property(node, "continuous_cd", old_val)
|
||||
applied["continuous_cd"] = ccd_str
|
||||
else:
|
||||
var old_val: bool = node.continuous_cd
|
||||
var new_val: bool = bool(params["continuous_cd"])
|
||||
undo_redo.add_do_property(node, "continuous_cd", new_val)
|
||||
undo_redo.add_undo_property(node, "continuous_cd", old_val)
|
||||
applied["continuous_cd"] = new_val
|
||||
|
||||
if params.has("contact_monitor"):
|
||||
var old_val: bool = node.contact_monitor
|
||||
var new_val: bool = bool(params["contact_monitor"])
|
||||
undo_redo.add_do_property(node, "contact_monitor", new_val)
|
||||
undo_redo.add_undo_property(node, "contact_monitor", old_val)
|
||||
applied["contact_monitor"] = new_val
|
||||
|
||||
if params.has("max_contacts_reported"):
|
||||
var old_val: int = node.max_contacts_reported
|
||||
var new_val: int = int(params["max_contacts_reported"])
|
||||
undo_redo.add_do_property(node, "max_contacts_reported", new_val)
|
||||
undo_redo.add_undo_property(node, "max_contacts_reported", old_val)
|
||||
applied["max_contacts_reported"] = new_val
|
||||
|
||||
elif node is StaticBody2D or node is StaticBody3D or node is AnimatableBody2D or node is AnimatableBody3D:
|
||||
# StaticBody / AnimatableBody shared properties
|
||||
if params.has("physics_material_override"):
|
||||
# We just note it — use add_resource for complex resource assignment
|
||||
return error_invalid_params("Use add_resource to set physics_material_override")
|
||||
else:
|
||||
return error_invalid_params("Node '%s' (%s) is not a recognized physics body type. Supported: CharacterBody2D/3D, RigidBody2D/3D, StaticBody2D/3D, AnimatableBody2D/3D" % [node_path, node.get_class()])
|
||||
|
||||
if applied.is_empty():
|
||||
undo_redo.commit_action()
|
||||
return error_invalid_params("No valid properties provided for %s" % node.get_class())
|
||||
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"type": node.get_class(),
|
||||
"applied": applied,
|
||||
})
|
||||
|
||||
|
||||
func _get_collision_info(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
var include_children: bool = optional_bool(params, "include_children", true)
|
||||
|
||||
var info: Dictionary = {
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"type": node.get_class(),
|
||||
}
|
||||
|
||||
# Collect physics body properties
|
||||
if "collision_layer" in node:
|
||||
var dim := _detect_dimension(node)
|
||||
if dim.is_empty():
|
||||
dim = "2d"
|
||||
info["collision_layer"] = node.get("collision_layer")
|
||||
info["collision_layer_info"] = _layer_bitmask_to_info(int(node.get("collision_layer")), dim)
|
||||
info["collision_mask"] = node.get("collision_mask")
|
||||
info["collision_mask_info"] = _layer_bitmask_to_info(int(node.get("collision_mask")), dim)
|
||||
|
||||
# Collect body-specific properties
|
||||
if node is CharacterBody2D or node is CharacterBody3D:
|
||||
info["body_settings"] = {
|
||||
"motion_mode": node.motion_mode,
|
||||
"floor_stop_on_slope": node.floor_stop_on_slope,
|
||||
"floor_max_angle": node.floor_max_angle,
|
||||
"floor_snap_length": node.floor_snap_length,
|
||||
"wall_min_slide_angle": node.wall_min_slide_angle,
|
||||
"max_slides": node.max_slides,
|
||||
"slide_on_ceiling": node.slide_on_ceiling,
|
||||
}
|
||||
elif node is RigidBody2D or node is RigidBody3D:
|
||||
info["body_settings"] = {
|
||||
"mass": node.mass,
|
||||
"gravity_scale": node.gravity_scale,
|
||||
"linear_damp": node.linear_damp,
|
||||
"angular_damp": node.angular_damp,
|
||||
"freeze": node.freeze,
|
||||
"freeze_mode": node.freeze_mode,
|
||||
"contact_monitor": node.contact_monitor,
|
||||
"max_contacts_reported": node.max_contacts_reported,
|
||||
}
|
||||
|
||||
# Collect collision shapes
|
||||
var shapes: Array = []
|
||||
var raycasts: Array = []
|
||||
var nodes_to_check: Array = [node]
|
||||
|
||||
if include_children:
|
||||
var queue: Array = [node]
|
||||
while queue.size() > 0:
|
||||
var current: Node = queue.pop_front()
|
||||
for child_idx in current.get_child_count():
|
||||
var child := current.get_child(child_idx)
|
||||
nodes_to_check.append(child)
|
||||
queue.append(child)
|
||||
|
||||
for check_node: Node in nodes_to_check:
|
||||
if check_node is CollisionShape2D:
|
||||
var shape_info: Dictionary = {
|
||||
"node_path": str(root.get_path_to(check_node)),
|
||||
"disabled": check_node.disabled,
|
||||
"one_way_collision": check_node.one_way_collision,
|
||||
}
|
||||
if check_node.shape != null:
|
||||
shape_info["shape_type"] = check_node.shape.get_class()
|
||||
if check_node.shape is RectangleShape2D:
|
||||
shape_info["size"] = "Vector2(%s, %s)" % [check_node.shape.size.x, check_node.shape.size.y]
|
||||
elif check_node.shape is CircleShape2D:
|
||||
shape_info["radius"] = check_node.shape.radius
|
||||
elif check_node.shape is CapsuleShape2D:
|
||||
shape_info["radius"] = check_node.shape.radius
|
||||
shape_info["height"] = check_node.shape.height
|
||||
shapes.append(shape_info)
|
||||
|
||||
elif check_node is CollisionShape3D:
|
||||
var shape_info: Dictionary = {
|
||||
"node_path": str(root.get_path_to(check_node)),
|
||||
"disabled": check_node.disabled,
|
||||
}
|
||||
if check_node.shape != null:
|
||||
shape_info["shape_type"] = check_node.shape.get_class()
|
||||
if check_node.shape is BoxShape3D:
|
||||
shape_info["size"] = "Vector3(%s, %s, %s)" % [check_node.shape.size.x, check_node.shape.size.y, check_node.shape.size.z]
|
||||
elif check_node.shape is SphereShape3D:
|
||||
shape_info["radius"] = check_node.shape.radius
|
||||
elif check_node.shape is CapsuleShape3D:
|
||||
shape_info["radius"] = check_node.shape.radius
|
||||
shape_info["height"] = check_node.shape.height
|
||||
elif check_node.shape is CylinderShape3D:
|
||||
shape_info["radius"] = check_node.shape.radius
|
||||
shape_info["height"] = check_node.shape.height
|
||||
shapes.append(shape_info)
|
||||
|
||||
elif check_node is CollisionPolygon2D:
|
||||
shapes.append({
|
||||
"node_path": str(root.get_path_to(check_node)),
|
||||
"shape_type": "CollisionPolygon2D",
|
||||
"disabled": check_node.disabled,
|
||||
"one_way_collision": check_node.one_way_collision,
|
||||
"polygon_points": check_node.polygon.size(),
|
||||
})
|
||||
|
||||
elif check_node is CollisionPolygon3D:
|
||||
shapes.append({
|
||||
"node_path": str(root.get_path_to(check_node)),
|
||||
"shape_type": "CollisionPolygon3D",
|
||||
"disabled": check_node.disabled,
|
||||
"polygon_points": check_node.polygon.size(),
|
||||
})
|
||||
|
||||
elif check_node is RayCast2D:
|
||||
raycasts.append({
|
||||
"node_path": str(root.get_path_to(check_node)),
|
||||
"type": "RayCast2D",
|
||||
"enabled": check_node.enabled,
|
||||
"target_position": "Vector2(%s, %s)" % [check_node.target_position.x, check_node.target_position.y],
|
||||
"collision_mask": check_node.collision_mask,
|
||||
"collide_with_areas": check_node.collide_with_areas,
|
||||
"collide_with_bodies": check_node.collide_with_bodies,
|
||||
})
|
||||
|
||||
elif check_node is RayCast3D:
|
||||
raycasts.append({
|
||||
"node_path": str(root.get_path_to(check_node)),
|
||||
"type": "RayCast3D",
|
||||
"enabled": check_node.enabled,
|
||||
"target_position": "Vector3(%s, %s, %s)" % [check_node.target_position.x, check_node.target_position.y, check_node.target_position.z],
|
||||
"collision_mask": check_node.collision_mask,
|
||||
"collide_with_areas": check_node.collide_with_areas,
|
||||
"collide_with_bodies": check_node.collide_with_bodies,
|
||||
})
|
||||
|
||||
info["collision_shapes"] = shapes
|
||||
info["raycasts"] = raycasts
|
||||
|
||||
return success(info)
|
||||
@@ -0,0 +1,69 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_performance_monitors": _get_performance_monitors,
|
||||
"get_editor_performance": _get_editor_performance,
|
||||
}
|
||||
|
||||
|
||||
func _get_performance_monitors(params: Dictionary) -> Dictionary:
|
||||
# Return all available performance monitors
|
||||
var monitors := {}
|
||||
monitors["fps"] = Performance.get_monitor(Performance.TIME_FPS)
|
||||
monitors["frame_time_msec"] = Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
|
||||
monitors["physics_frame_time_msec"] = Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
|
||||
monitors["navigation_process_msec"] = Performance.get_monitor(Performance.TIME_NAVIGATION_PROCESS) * 1000.0
|
||||
|
||||
monitors["memory_static"] = Performance.get_monitor(Performance.MEMORY_STATIC)
|
||||
monitors["memory_static_max"] = Performance.get_monitor(Performance.MEMORY_STATIC_MAX)
|
||||
|
||||
monitors["object_count"] = Performance.get_monitor(Performance.OBJECT_COUNT)
|
||||
monitors["object_resource_count"] = Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT)
|
||||
monitors["object_node_count"] = Performance.get_monitor(Performance.OBJECT_NODE_COUNT)
|
||||
monitors["object_orphan_node_count"] = Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)
|
||||
|
||||
monitors["render_total_objects_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)
|
||||
monitors["render_total_primitives_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)
|
||||
monitors["render_total_draw_calls_in_frame"] = Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)
|
||||
monitors["render_video_mem_used"] = Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)
|
||||
|
||||
monitors["physics_2d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_2D_ACTIVE_OBJECTS)
|
||||
monitors["physics_2d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_2D_COLLISION_PAIRS)
|
||||
monitors["physics_2d_island_count"] = Performance.get_monitor(Performance.PHYSICS_2D_ISLAND_COUNT)
|
||||
|
||||
monitors["physics_3d_active_objects"] = Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS)
|
||||
monitors["physics_3d_collision_pairs"] = Performance.get_monitor(Performance.PHYSICS_3D_COLLISION_PAIRS)
|
||||
monitors["physics_3d_island_count"] = Performance.get_monitor(Performance.PHYSICS_3D_ISLAND_COUNT)
|
||||
|
||||
monitors["navigation_active_maps"] = Performance.get_monitor(Performance.NAVIGATION_ACTIVE_MAPS)
|
||||
monitors["navigation_region_count"] = Performance.get_monitor(Performance.NAVIGATION_REGION_COUNT)
|
||||
monitors["navigation_agent_count"] = Performance.get_monitor(Performance.NAVIGATION_AGENT_COUNT)
|
||||
|
||||
# Filter by category if requested
|
||||
var category: String = optional_string(params, "category", "")
|
||||
if not category.is_empty():
|
||||
var filtered := {}
|
||||
for key: String in monitors:
|
||||
if key.begins_with(category):
|
||||
filtered[key] = monitors[key]
|
||||
return success({"monitors": filtered, "category": category})
|
||||
|
||||
return success({"monitors": monitors})
|
||||
|
||||
|
||||
func _get_editor_performance(params: Dictionary) -> Dictionary:
|
||||
# Quick summary for common use
|
||||
var summary := {
|
||||
"fps": Performance.get_monitor(Performance.TIME_FPS),
|
||||
"frame_time_msec": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
|
||||
"draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
|
||||
"objects_in_frame": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME),
|
||||
"node_count": Performance.get_monitor(Performance.OBJECT_NODE_COUNT),
|
||||
"orphan_nodes": Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT),
|
||||
"memory_static_mb": Performance.get_monitor(Performance.MEMORY_STATIC) / (1024.0 * 1024.0),
|
||||
"video_mem_mb": Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED) / (1024.0 * 1024.0),
|
||||
}
|
||||
return success(summary)
|
||||
@@ -0,0 +1,390 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_project_info": _get_project_info,
|
||||
"get_filesystem_tree": _get_filesystem_tree,
|
||||
"search_files": _search_files,
|
||||
"search_in_files": _search_in_files,
|
||||
"get_project_settings": _get_project_settings,
|
||||
"set_project_setting": _set_project_setting,
|
||||
"uid_to_project_path": _uid_to_project_path,
|
||||
"project_path_to_uid": _project_path_to_uid,
|
||||
"add_autoload": _add_autoload,
|
||||
"remove_autoload": _remove_autoload,
|
||||
}
|
||||
|
||||
|
||||
func _get_project_info(params: Dictionary) -> Dictionary:
|
||||
var info := {}
|
||||
info["project_name"] = ProjectSettings.get_setting("application/config/name", "")
|
||||
info["godot_version"] = Engine.get_version_info()
|
||||
info["project_path"] = ProjectSettings.globalize_path("res://")
|
||||
info["main_scene"] = ProjectSettings.get_setting("application/run/main_scene", "")
|
||||
|
||||
# Viewport settings
|
||||
info["viewport_width"] = ProjectSettings.get_setting("display/window/size/viewport_width", 0)
|
||||
info["viewport_height"] = ProjectSettings.get_setting("display/window/size/viewport_height", 0)
|
||||
info["window_width"] = ProjectSettings.get_setting("display/window/size/window_width_override", 0)
|
||||
info["window_height"] = ProjectSettings.get_setting("display/window/size/window_height_override", 0)
|
||||
|
||||
# Rendering
|
||||
info["renderer"] = ProjectSettings.get_setting("rendering/renderer/rendering_method", "")
|
||||
|
||||
# Autoloads
|
||||
var autoloads := {}
|
||||
for prop in ProjectSettings.get_property_list():
|
||||
var name: String = prop["name"]
|
||||
if name.begins_with("autoload/"):
|
||||
autoloads[name.substr(9)] = ProjectSettings.get_setting(name)
|
||||
info["autoloads"] = autoloads
|
||||
|
||||
return success(info)
|
||||
|
||||
|
||||
func _get_filesystem_tree(params: Dictionary) -> Dictionary:
|
||||
var path: String = optional_string(params, "path", "res://")
|
||||
var filter: String = optional_string(params, "filter", "") # e.g. "*.gd", "*.tscn"
|
||||
var max_depth: int = optional_int(params, "max_depth", 10)
|
||||
|
||||
var tree := _scan_directory(path, filter, max_depth, 0)
|
||||
return success({"tree": tree})
|
||||
|
||||
|
||||
func _scan_directory(path: String, filter: String, max_depth: int, depth: int) -> Dictionary:
|
||||
var result := {"name": path.get_file(), "path": path, "type": "directory"}
|
||||
|
||||
if depth >= max_depth:
|
||||
return result
|
||||
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return result
|
||||
|
||||
var children: Array = []
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
|
||||
while not file_name.is_empty():
|
||||
if file_name.begins_with("."):
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(file_name)
|
||||
|
||||
if dir.current_is_dir():
|
||||
children.append(_scan_directory(full_path, filter, max_depth, depth + 1))
|
||||
else:
|
||||
if filter.is_empty() or file_name.match(filter):
|
||||
children.append({
|
||||
"name": file_name,
|
||||
"path": full_path,
|
||||
"type": "file",
|
||||
})
|
||||
|
||||
file_name = dir.get_next()
|
||||
|
||||
dir.list_dir_end()
|
||||
|
||||
if not children.is_empty():
|
||||
result["children"] = children
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func _search_files(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "query")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var query: String = result[0]
|
||||
|
||||
var path: String = optional_string(params, "path", "res://")
|
||||
var file_type: String = optional_string(params, "file_type", "") # e.g. "gd", "tscn"
|
||||
var max_results: int = optional_int(params, "max_results", 50)
|
||||
|
||||
var matches: Array = []
|
||||
_search_recursive(path, query, file_type, matches, max_results)
|
||||
|
||||
return success({"matches": matches, "count": matches.size()})
|
||||
|
||||
|
||||
func _search_recursive(path: String, query: String, file_type: String, matches: Array, max_results: int) -> void:
|
||||
if matches.size() >= max_results:
|
||||
return
|
||||
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
|
||||
while not file_name.is_empty() and matches.size() < max_results:
|
||||
if file_name.begins_with("."):
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(file_name)
|
||||
|
||||
if dir.current_is_dir():
|
||||
_search_recursive(full_path, query, file_type, matches, max_results)
|
||||
else:
|
||||
# Check file type filter
|
||||
if not file_type.is_empty() and file_name.get_extension() != file_type:
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
|
||||
# Fuzzy match: check if query is contained in filename (case insensitive)
|
||||
if file_name.to_lower().contains(query.to_lower()):
|
||||
matches.append(full_path)
|
||||
# Also check glob pattern
|
||||
elif file_name.match(query):
|
||||
matches.append(full_path)
|
||||
|
||||
file_name = dir.get_next()
|
||||
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func _get_project_settings(params: Dictionary) -> Dictionary:
|
||||
var section: String = optional_string(params, "section", "")
|
||||
var key: String = optional_string(params, "key", "")
|
||||
|
||||
# If specific key requested
|
||||
if not key.is_empty():
|
||||
if ProjectSettings.has_setting(key):
|
||||
var value = ProjectSettings.get_setting(key)
|
||||
return success({"key": key, "value": str(value), "type": typeof(value)})
|
||||
else:
|
||||
return error_not_found("Setting '%s'" % key)
|
||||
|
||||
# If section requested, return all settings in that section
|
||||
var settings := {}
|
||||
for prop in ProjectSettings.get_property_list():
|
||||
var name: String = prop["name"]
|
||||
if section.is_empty() or name.begins_with(section):
|
||||
settings[name] = str(ProjectSettings.get_setting(name))
|
||||
|
||||
return success({"settings": settings, "count": settings.size()})
|
||||
|
||||
|
||||
func _set_project_setting(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "key")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var key: String = result[0]
|
||||
|
||||
if not params.has("value"):
|
||||
return error_invalid_params("Missing required parameter: value")
|
||||
|
||||
var value = params["value"]
|
||||
|
||||
# Type conversion for common patterns
|
||||
if value is String:
|
||||
var s: String = value
|
||||
# Try to parse typed values from string
|
||||
if s.begins_with("Vector2("):
|
||||
var expr := Expression.new()
|
||||
if expr.parse(s) == OK:
|
||||
var parsed = expr.execute()
|
||||
if parsed is Vector2:
|
||||
value = parsed
|
||||
elif s == "true":
|
||||
value = true
|
||||
elif s == "false":
|
||||
value = false
|
||||
elif s.is_valid_int():
|
||||
value = s.to_int()
|
||||
elif s.is_valid_float():
|
||||
value = s.to_float()
|
||||
|
||||
ProjectSettings.set_setting(key, value)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return error_internal("Failed to save project settings: %s" % error_string(err))
|
||||
|
||||
return success({
|
||||
"key": key,
|
||||
"value": str(ProjectSettings.get_setting(key)),
|
||||
"saved": true,
|
||||
})
|
||||
|
||||
|
||||
func _uid_to_project_path(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "uid")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var uid_str: String = result[0]
|
||||
|
||||
# Use ResourceUID to convert
|
||||
var uid := ResourceUID.text_to_id(uid_str)
|
||||
if uid == ResourceUID.INVALID_ID:
|
||||
return error_invalid_params("Invalid UID format: %s" % uid_str)
|
||||
|
||||
if not ResourceUID.has_id(uid):
|
||||
return error_not_found("UID '%s'" % uid_str)
|
||||
|
||||
var path := ResourceUID.get_id_path(uid)
|
||||
return success({"uid": uid_str, "path": path})
|
||||
|
||||
|
||||
func _project_path_to_uid(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not ResourceLoader.exists(path):
|
||||
return error_not_found("Resource at '%s'" % path)
|
||||
|
||||
var uid := ResourceLoader.get_resource_uid(path)
|
||||
if uid == ResourceUID.INVALID_ID:
|
||||
return error(-32001, "No UID assigned to '%s'" % path)
|
||||
|
||||
var uid_str := ResourceUID.id_to_text(uid)
|
||||
return success({"path": path, "uid": uid_str})
|
||||
|
||||
|
||||
const _TEXT_EXTENSIONS: PackedStringArray = ["gd", "tscn", "tres", "cfg", "godot", "gdshader", "md", "txt", "json"]
|
||||
|
||||
func _search_in_files(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "query")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var query: String = result[0]
|
||||
|
||||
var path: String = optional_string(params, "path", "res://")
|
||||
var max_results: int = optional_int(params, "max_results", 50)
|
||||
var use_regex: bool = optional_bool(params, "regex", false)
|
||||
var file_type: String = optional_string(params, "file_type", "")
|
||||
|
||||
var regex: RegEx = null
|
||||
if use_regex:
|
||||
regex = RegEx.new()
|
||||
var err := regex.compile(query)
|
||||
if err != OK:
|
||||
return error_invalid_params("Invalid regex pattern: %s" % error_string(err))
|
||||
|
||||
var matches: Array = []
|
||||
_search_in_files_recursive(path, query, regex, file_type, matches, max_results)
|
||||
|
||||
return success({"matches": matches, "count": matches.size(), "query": query})
|
||||
|
||||
|
||||
func _search_in_files_recursive(path: String, query: String, regex: RegEx, file_type: String, matches: Array, max_results: int) -> void:
|
||||
if matches.size() >= max_results:
|
||||
return
|
||||
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
|
||||
while not file_name.is_empty() and matches.size() < max_results:
|
||||
if file_name.begins_with("."):
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(file_name)
|
||||
|
||||
if dir.current_is_dir():
|
||||
# Skip addons and .godot directories
|
||||
if file_name != "addons" and file_name != ".godot":
|
||||
_search_in_files_recursive(full_path, query, regex, file_type, matches, max_results)
|
||||
else:
|
||||
var ext := file_name.get_extension()
|
||||
# Filter by file type if specified, otherwise use text extensions
|
||||
if not file_type.is_empty():
|
||||
if ext != file_type:
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
elif ext not in _TEXT_EXTENSIONS:
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
|
||||
var file := FileAccess.open(full_path, FileAccess.READ)
|
||||
if file:
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
var lines := content.split("\n")
|
||||
for i in range(lines.size()):
|
||||
if matches.size() >= max_results:
|
||||
break
|
||||
var line: String = lines[i]
|
||||
var matched := false
|
||||
if regex != null:
|
||||
matched = regex.search(line) != null
|
||||
else:
|
||||
matched = line.contains(query)
|
||||
if matched:
|
||||
matches.append({
|
||||
"file": full_path,
|
||||
"line": i + 1,
|
||||
"text": line.strip_edges(),
|
||||
})
|
||||
|
||||
file_name = dir.get_next()
|
||||
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func _add_autoload(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "name")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var autoload_name: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "path")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var autoload_path: String = result2[0]
|
||||
|
||||
if not FileAccess.file_exists(autoload_path):
|
||||
return error_not_found("File '%s'" % autoload_path)
|
||||
|
||||
# Check if already exists
|
||||
var setting_key := "autoload/" + autoload_name
|
||||
if ProjectSettings.has_setting(setting_key):
|
||||
return error(-32000, "Autoload '%s' already exists" % autoload_name, {
|
||||
"current_value": str(ProjectSettings.get_setting(setting_key)),
|
||||
"suggestion": "Use remove_autoload first to replace it",
|
||||
})
|
||||
|
||||
# Autoload format: "*res://path.gd" (the * prefix means it's a singleton)
|
||||
ProjectSettings.set_setting(setting_key, "*" + autoload_path)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return error_internal("Failed to save project settings: %s" % error_string(err))
|
||||
|
||||
return success({
|
||||
"name": autoload_name,
|
||||
"path": autoload_path,
|
||||
"added": true,
|
||||
})
|
||||
|
||||
|
||||
func _remove_autoload(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "name")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var autoload_name: String = result[0]
|
||||
|
||||
var setting_key := "autoload/" + autoload_name
|
||||
if not ProjectSettings.has_setting(setting_key):
|
||||
return error_not_found("Autoload '%s'" % autoload_name)
|
||||
|
||||
var old_value: String = str(ProjectSettings.get_setting(setting_key))
|
||||
ProjectSettings.clear(setting_key)
|
||||
var err := ProjectSettings.save()
|
||||
if err != OK:
|
||||
return error_internal("Failed to save project settings: %s" % error_string(err))
|
||||
|
||||
return success({
|
||||
"name": autoload_name,
|
||||
"old_path": old_value,
|
||||
"removed": true,
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"read_resource": _read_resource,
|
||||
"edit_resource": _edit_resource,
|
||||
"create_resource": _create_resource,
|
||||
"get_resource_preview": _get_resource_preview,
|
||||
}
|
||||
|
||||
|
||||
func _read_resource(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Resource '%s'" % path)
|
||||
|
||||
var guard := guard_offline_scene_save(path)
|
||||
if not guard.is_empty():
|
||||
return guard
|
||||
|
||||
var resource: Resource = ResourceLoader.load(path)
|
||||
if resource == null:
|
||||
return error_internal("Failed to load resource: %s" % path)
|
||||
|
||||
var props: Dictionary = {}
|
||||
for prop_info in resource.get_property_list():
|
||||
var prop_name: String = prop_info["name"]
|
||||
var usage: int = prop_info["usage"]
|
||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
if prop_name.begins_with("_") or prop_name == "script" or prop_name == "resource_local_to_scene" or prop_name == "resource_name" or prop_name == "resource_path":
|
||||
continue
|
||||
props[prop_name] = PropertyParser.serialize_value(resource.get(prop_name))
|
||||
|
||||
return success({
|
||||
"path": path,
|
||||
"type": resource.get_class(),
|
||||
"resource_name": resource.resource_name,
|
||||
"properties": props,
|
||||
})
|
||||
|
||||
|
||||
func _edit_resource(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not params.has("properties") or not params["properties"] is Dictionary:
|
||||
return error_invalid_params("'properties' dictionary is required")
|
||||
var new_props: Dictionary = params["properties"]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Resource '%s'" % path)
|
||||
|
||||
var guard := guard_offline_scene_save(path)
|
||||
if not guard.is_empty():
|
||||
return guard
|
||||
|
||||
var resource: Resource = ResourceLoader.load(path)
|
||||
if resource == null:
|
||||
return error_internal("Failed to load resource: %s" % path)
|
||||
|
||||
var changed: Dictionary = {}
|
||||
for prop_name: String in new_props:
|
||||
if not prop_name in resource:
|
||||
continue
|
||||
var old_value: Variant = resource.get(prop_name)
|
||||
var target_type := typeof(old_value)
|
||||
var new_value: Variant = PropertyParser.parse_value(new_props[prop_name], target_type)
|
||||
resource.set(prop_name, new_value)
|
||||
changed[prop_name] = {
|
||||
"old": PropertyParser.serialize_value(old_value),
|
||||
"new": PropertyParser.serialize_value(resource.get(prop_name)),
|
||||
}
|
||||
|
||||
if changed.is_empty():
|
||||
return success({"path": path, "changed": {}, "message": "No properties were changed"})
|
||||
|
||||
var err := ResourceSaver.save(resource, path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to save resource: %s" % error_string(err))
|
||||
|
||||
return success({
|
||||
"path": path,
|
||||
"type": resource.get_class(),
|
||||
"changed": changed,
|
||||
})
|
||||
|
||||
|
||||
func _create_resource(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "type")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var resource_type: String = result2[0]
|
||||
|
||||
if not ClassDB.class_exists(resource_type):
|
||||
return error_invalid_params("Unknown resource type: %s" % resource_type)
|
||||
if not ClassDB.is_parent_class(resource_type, "Resource"):
|
||||
return error_invalid_params("'%s' is not a Resource type" % resource_type)
|
||||
|
||||
var overwrite: bool = optional_bool(params, "overwrite", false)
|
||||
if FileAccess.file_exists(path) and not overwrite:
|
||||
return error(-32000, "Resource already exists: %s" % path, {"suggestion": "Set overwrite=true to replace"})
|
||||
|
||||
var guard := guard_offline_scene_save(path)
|
||||
if not guard.is_empty():
|
||||
return guard
|
||||
|
||||
var resource: Resource = ClassDB.instantiate(resource_type)
|
||||
if resource == null:
|
||||
return error_internal("Failed to instantiate: %s" % resource_type)
|
||||
|
||||
# Apply properties
|
||||
var properties: Dictionary = params.get("properties", {})
|
||||
for prop_name: String in properties:
|
||||
if prop_name in resource:
|
||||
var current: Variant = resource.get(prop_name)
|
||||
resource.set(prop_name, PropertyParser.parse_value(properties[prop_name], typeof(current)))
|
||||
|
||||
var err := ResourceSaver.save(resource, path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to save resource: %s" % error_string(err))
|
||||
|
||||
# Rescan filesystem
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
|
||||
return success({
|
||||
"path": path,
|
||||
"type": resource_type,
|
||||
"properties_set": properties.keys(),
|
||||
})
|
||||
|
||||
|
||||
func _get_resource_preview(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Resource '%s'" % path)
|
||||
|
||||
var max_size: int = optional_int(params, "max_size", 256)
|
||||
var image: Image = null
|
||||
|
||||
# Try loading as image file directly
|
||||
var ext := path.get_extension().to_lower()
|
||||
if ext in ["png", "jpg", "jpeg", "bmp", "webp", "svg"]:
|
||||
image = Image.new()
|
||||
var err := image.load(path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to load image: %s" % error_string(err))
|
||||
else:
|
||||
# Try loading as resource and extracting image
|
||||
var resource: Resource = ResourceLoader.load(path)
|
||||
if resource == null:
|
||||
return error_internal("Failed to load resource: %s" % path)
|
||||
|
||||
if resource is Texture2D:
|
||||
image = (resource as Texture2D).get_image()
|
||||
elif resource is Image:
|
||||
image = resource as Image
|
||||
else:
|
||||
return error_invalid_params("Resource type '%s' does not have an image preview" % resource.get_class())
|
||||
|
||||
if image == null:
|
||||
return error_internal("Could not extract image from resource")
|
||||
|
||||
# Resize if needed
|
||||
if image.get_width() > max_size or image.get_height() > max_size:
|
||||
var scale_x := float(max_size) / float(image.get_width())
|
||||
var scale_y := float(max_size) / float(image.get_height())
|
||||
var scale := minf(scale_x, scale_y)
|
||||
var new_w := int(image.get_width() * scale)
|
||||
var new_h := int(image.get_height() * scale)
|
||||
image.resize(new_w, new_h, Image.INTERPOLATE_LANCZOS)
|
||||
|
||||
var png_buffer := image.save_png_to_buffer()
|
||||
var base64 := Marshalls.raw_to_base64(png_buffer)
|
||||
|
||||
return success({
|
||||
"image_base64": base64,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height(),
|
||||
"format": "png",
|
||||
"path": path,
|
||||
})
|
||||
@@ -0,0 +1,398 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
## Editor-side commands for runtime game inspection.
|
||||
## Communicates with MCPGameInspector autoload via file-based IPC.
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_game_scene_tree": _get_game_scene_tree,
|
||||
"get_game_node_properties": _get_game_node_properties,
|
||||
"set_game_node_property": _set_game_node_property,
|
||||
"capture_frames": _capture_frames,
|
||||
"monitor_properties": _monitor_properties,
|
||||
"execute_game_script": _execute_game_script,
|
||||
"start_recording": _start_recording,
|
||||
"stop_recording": _stop_recording,
|
||||
"replay_recording": _replay_recording,
|
||||
"find_nodes_by_script": _find_nodes_by_script,
|
||||
"get_autoload": _get_autoload,
|
||||
"batch_get_properties": _batch_get_properties,
|
||||
"find_ui_elements": _find_ui_elements,
|
||||
"click_button_by_text": _click_button_by_text,
|
||||
"wait_for_node": _wait_for_node,
|
||||
"find_nearby_nodes": _find_nearby_nodes,
|
||||
"navigate_to": _navigate_to,
|
||||
"move_to": _move_to,
|
||||
"watch_signals": _watch_signals,
|
||||
}
|
||||
|
||||
|
||||
func _get_game_scene_tree(params: Dictionary) -> Dictionary:
|
||||
var max_depth: int = optional_int(params, "max_depth", -1)
|
||||
var cmd_params := {"max_depth": max_depth}
|
||||
|
||||
var script_filter: String = optional_string(params, "script_filter")
|
||||
if not script_filter.is_empty():
|
||||
cmd_params["script_filter"] = script_filter
|
||||
|
||||
var type_filter: String = optional_string(params, "type_filter")
|
||||
if not type_filter.is_empty():
|
||||
cmd_params["type_filter"] = type_filter
|
||||
|
||||
var named_only: bool = optional_bool(params, "named_only", false)
|
||||
if named_only:
|
||||
cmd_params["named_only"] = true
|
||||
|
||||
return await _send_game_command("get_scene_tree", cmd_params)
|
||||
|
||||
|
||||
func _get_game_node_properties(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var cmd_params := {"node_path": result[0]}
|
||||
# Optional property filter
|
||||
if params.has("properties") and params["properties"] is Array:
|
||||
cmd_params["properties"] = params["properties"]
|
||||
|
||||
return await _send_game_command("get_node_properties", cmd_params)
|
||||
|
||||
|
||||
func _set_game_node_property(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var prop_result := require_string(params, "property")
|
||||
if prop_result[1] != null:
|
||||
return prop_result[1]
|
||||
|
||||
if not params.has("value"):
|
||||
return error_invalid_params("Missing required parameter: value")
|
||||
|
||||
return await _send_game_command("set_node_property", {
|
||||
"node_path": result[0],
|
||||
"property": prop_result[0],
|
||||
"value": params["value"],
|
||||
})
|
||||
|
||||
|
||||
func _execute_game_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "code")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
return await _send_game_command("execute_script", {
|
||||
"code": result[0],
|
||||
}, 10.0)
|
||||
|
||||
|
||||
func _capture_frames(params: Dictionary) -> Dictionary:
|
||||
var count: int = optional_int(params, "count", 5)
|
||||
var frame_interval: int = optional_int(params, "frame_interval", 10)
|
||||
var half_resolution: bool = optional_bool(params, "half_resolution", true)
|
||||
|
||||
# Dynamic timeout: allow enough time for frame capture
|
||||
# At 60fps, 30 frames * 10 interval = 300 frames = 5 seconds + overhead
|
||||
var estimated_seconds: float = (count * frame_interval) / 60.0 + 2.0
|
||||
var timeout := minf(estimated_seconds, 25.0)
|
||||
|
||||
return await _send_game_command("capture_frames", {
|
||||
"count": count,
|
||||
"frame_interval": frame_interval,
|
||||
"half_resolution": half_resolution,
|
||||
}, timeout)
|
||||
|
||||
|
||||
func _monitor_properties(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
if not params.has("properties") or not params["properties"] is Array:
|
||||
return error_invalid_params("'properties' array is required")
|
||||
|
||||
var frame_count: int = optional_int(params, "frame_count", 60)
|
||||
var frame_interval: int = optional_int(params, "frame_interval", 1)
|
||||
|
||||
# Dynamic timeout
|
||||
var estimated_seconds: float = (frame_count * frame_interval) / 60.0 + 2.0
|
||||
var timeout := minf(estimated_seconds, 25.0)
|
||||
|
||||
return await _send_game_command("monitor_properties", {
|
||||
"node_path": result[0],
|
||||
"properties": params["properties"],
|
||||
"frame_count": frame_count,
|
||||
"frame_interval": frame_interval,
|
||||
}, timeout)
|
||||
|
||||
|
||||
func _start_recording(params: Dictionary) -> Dictionary:
|
||||
return await _send_game_command("start_recording", {})
|
||||
|
||||
|
||||
func _stop_recording(params: Dictionary) -> Dictionary:
|
||||
return await _send_game_command("stop_recording", {}, 5.0)
|
||||
|
||||
|
||||
func _replay_recording(params: Dictionary) -> Dictionary:
|
||||
if not params.has("events") or not params["events"] is Array:
|
||||
return error_invalid_params("'events' array is required")
|
||||
var speed: float = float(params.get("speed", 1.0))
|
||||
|
||||
# Calculate timeout based on event duration
|
||||
var max_time_ms: int = 0
|
||||
for event_data: Dictionary in params["events"]:
|
||||
var t: int = int(event_data.get("time_ms", 0))
|
||||
if t > max_time_ms:
|
||||
max_time_ms = t
|
||||
var timeout := (max_time_ms / 1000.0 / speed) + 5.0
|
||||
|
||||
return await _send_game_command("replay_recording", {
|
||||
"events": params["events"],
|
||||
"speed": speed,
|
||||
}, minf(timeout, 120.0))
|
||||
|
||||
|
||||
func _find_nodes_by_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "script")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var cmd_params := {"script": result[0]}
|
||||
if params.has("properties") and params["properties"] is Array:
|
||||
cmd_params["properties"] = params["properties"]
|
||||
|
||||
return await _send_game_command("find_nodes_by_script", cmd_params)
|
||||
|
||||
|
||||
func _get_autoload(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "name")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var cmd_params := {"name": result[0]}
|
||||
if params.has("properties") and params["properties"] is Array:
|
||||
cmd_params["properties"] = params["properties"]
|
||||
|
||||
return await _send_game_command("get_autoload", cmd_params)
|
||||
|
||||
|
||||
func _batch_get_properties(params: Dictionary) -> Dictionary:
|
||||
if not params.has("nodes") or not params["nodes"] is Array:
|
||||
return error_invalid_params("'nodes' array is required")
|
||||
|
||||
return await _send_game_command("batch_get_properties", {
|
||||
"nodes": params["nodes"],
|
||||
})
|
||||
|
||||
|
||||
func _find_ui_elements(params: Dictionary) -> Dictionary:
|
||||
var cmd_params := {}
|
||||
var type_filter: String = optional_string(params, "type_filter")
|
||||
if not type_filter.is_empty():
|
||||
cmd_params["type_filter"] = type_filter
|
||||
return await _send_game_command("find_ui_elements", cmd_params)
|
||||
|
||||
|
||||
func _click_button_by_text(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "text")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var cmd_params := {"text": result[0]}
|
||||
var partial: bool = optional_bool(params, "partial", true)
|
||||
cmd_params["partial"] = partial
|
||||
|
||||
return await _send_game_command("click_button_by_text", cmd_params)
|
||||
|
||||
|
||||
func _wait_for_node(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
|
||||
var timeout: float = float(params.get("timeout", 5.0))
|
||||
var poll_frames: int = optional_int(params, "poll_frames", 5)
|
||||
|
||||
return await _send_game_command("wait_for_node", {
|
||||
"node_path": result[0],
|
||||
"timeout": timeout,
|
||||
"poll_frames": poll_frames,
|
||||
}, timeout + 2.0)
|
||||
|
||||
|
||||
func _find_nearby_nodes(params: Dictionary) -> Dictionary:
|
||||
if not params.has("position"):
|
||||
return error_invalid_params("Missing required parameter: position")
|
||||
|
||||
var cmd_params: Dictionary = {"position": params["position"]}
|
||||
if params.has("radius"):
|
||||
cmd_params["radius"] = float(params["radius"])
|
||||
var type_filter: String = optional_string(params, "type_filter")
|
||||
if not type_filter.is_empty():
|
||||
cmd_params["type_filter"] = type_filter
|
||||
var group_filter: String = optional_string(params, "group_filter")
|
||||
if not group_filter.is_empty():
|
||||
cmd_params["group_filter"] = group_filter
|
||||
if params.has("max_results"):
|
||||
cmd_params["max_results"] = int(params["max_results"])
|
||||
|
||||
return await _send_game_command("find_nearby_nodes", cmd_params)
|
||||
|
||||
|
||||
func _navigate_to(params: Dictionary) -> Dictionary:
|
||||
if not params.has("target"):
|
||||
return error_invalid_params("Missing required parameter: target")
|
||||
|
||||
var cmd_params: Dictionary = {"target": params["target"]}
|
||||
var player_path: String = optional_string(params, "player_path")
|
||||
if not player_path.is_empty():
|
||||
cmd_params["player_path"] = player_path
|
||||
var camera_path: String = optional_string(params, "camera_path")
|
||||
if not camera_path.is_empty():
|
||||
cmd_params["camera_path"] = camera_path
|
||||
if params.has("move_speed"):
|
||||
cmd_params["move_speed"] = float(params["move_speed"])
|
||||
|
||||
return await _send_game_command("navigate_to", cmd_params)
|
||||
|
||||
|
||||
func _move_to(params: Dictionary) -> Dictionary:
|
||||
if not params.has("target"):
|
||||
return error_invalid_params("Missing required parameter: target")
|
||||
|
||||
var cmd_params: Dictionary = {"target": params["target"]}
|
||||
var player_path: String = optional_string(params, "player_path")
|
||||
if not player_path.is_empty():
|
||||
cmd_params["player_path"] = player_path
|
||||
var camera_path: String = optional_string(params, "camera_path")
|
||||
if not camera_path.is_empty():
|
||||
cmd_params["camera_path"] = camera_path
|
||||
if params.has("arrival_radius"):
|
||||
cmd_params["arrival_radius"] = float(params["arrival_radius"])
|
||||
if params.has("timeout"):
|
||||
cmd_params["timeout"] = float(params["timeout"])
|
||||
if params.has("run"):
|
||||
cmd_params["run"] = bool(params["run"])
|
||||
if params.has("look_at_target"):
|
||||
cmd_params["look_at_target"] = bool(params["look_at_target"])
|
||||
|
||||
# Dynamic timeout: game-side timeout + overhead for IPC polling
|
||||
var game_timeout: float = float(params.get("timeout", 15.0))
|
||||
var ipc_timeout: float = game_timeout + 5.0
|
||||
|
||||
return await _send_game_command("move_to", cmd_params, ipc_timeout)
|
||||
|
||||
|
||||
func _watch_signals(params: Dictionary) -> Dictionary:
|
||||
if not params.has("node_paths") or not params["node_paths"] is Array:
|
||||
return error_invalid_params("Missing required parameter: node_paths (Array)")
|
||||
|
||||
var cmd_params: Dictionary = {"node_paths": params["node_paths"]}
|
||||
if params.has("signal_filter") and params["signal_filter"] is Array:
|
||||
cmd_params["signal_filter"] = params["signal_filter"]
|
||||
var duration_ms: int = optional_int(params, "duration_ms", 5000)
|
||||
cmd_params["duration_ms"] = duration_ms
|
||||
|
||||
# Dynamic timeout: duration + overhead
|
||||
var timeout_sec: float = (duration_ms / 1000.0) + 5.0
|
||||
|
||||
return await _send_game_command("watch_signals", cmd_params, timeout_sec)
|
||||
|
||||
|
||||
# ── IPC Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _send_game_command(command: String, params: Dictionary, timeout_sec: float = 5.0) -> Dictionary:
|
||||
var ei := get_editor()
|
||||
if not ei.is_playing_scene():
|
||||
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
|
||||
|
||||
var user_dir := get_game_user_dir()
|
||||
var request_path := user_dir + "/mcp_game_request"
|
||||
var response_path := user_dir + "/mcp_game_response"
|
||||
|
||||
# Clean stale response
|
||||
if FileAccess.file_exists(response_path):
|
||||
DirAccess.remove_absolute(response_path)
|
||||
|
||||
# Write request
|
||||
var request_data := JSON.stringify({"command": command, "params": params})
|
||||
var req := FileAccess.open(request_path, FileAccess.WRITE)
|
||||
if req == null:
|
||||
return error_internal("Could not create game request file")
|
||||
req.store_string(request_data)
|
||||
req.close()
|
||||
|
||||
# Poll for response
|
||||
var attempts := int(timeout_sec / 0.1)
|
||||
while attempts > 0:
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
if FileAccess.file_exists(response_path):
|
||||
break
|
||||
# Check if game is still running
|
||||
if not ei.is_playing_scene():
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return error(-32000, "Game stopped during command execution")
|
||||
attempts -= 1
|
||||
|
||||
if not FileAccess.file_exists(response_path):
|
||||
# Try to auto-resume the debugger (runtime error may have paused the game)
|
||||
if ei.is_playing_scene():
|
||||
_try_debugger_continue()
|
||||
# Give the game a chance to recover and write a response
|
||||
for _retry in 20:
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
if FileAccess.file_exists(response_path):
|
||||
break
|
||||
|
||||
if not FileAccess.file_exists(response_path):
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return error(-32000, "Game command timed out after %.1fs" % timeout_sec, {
|
||||
"suggestion": "Ensure the game is running and MCPGameInspector autoload is active",
|
||||
})
|
||||
|
||||
# Read response
|
||||
var file := FileAccess.open(response_path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Could not read game response file")
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
DirAccess.remove_absolute(response_path)
|
||||
|
||||
var parsed = JSON.parse_string(text)
|
||||
if parsed == null or not parsed is Dictionary:
|
||||
return error_internal("Invalid response JSON from game")
|
||||
|
||||
if parsed.has("error"):
|
||||
return error(-32000, str(parsed["error"]))
|
||||
|
||||
return success(parsed)
|
||||
|
||||
|
||||
## Press the debugger "Continue" button to resume a paused game process.
|
||||
func _try_debugger_continue() -> void:
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return
|
||||
var queue: Array[Node] = [base]
|
||||
while not queue.is_empty():
|
||||
var node := queue.pop_front()
|
||||
if node.get_class() == "ScriptEditorDebugger":
|
||||
var inner: Array[Node] = [node]
|
||||
while not inner.is_empty():
|
||||
var n := inner.pop_front()
|
||||
if n is Button and n.tooltip_text == "Continue":
|
||||
n.emit_signal("pressed")
|
||||
push_warning("[MCP] Auto-resumed debugger after runtime error")
|
||||
return
|
||||
for c in n.get_children():
|
||||
inner.append(c)
|
||||
return
|
||||
for child in node.get_children():
|
||||
queue.append(child)
|
||||
@@ -0,0 +1,680 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
|
||||
const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd")
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"add_mesh_instance": _add_mesh_instance,
|
||||
"setup_lighting": _setup_lighting,
|
||||
"set_material_3d": _set_material_3d,
|
||||
"setup_environment": _setup_environment,
|
||||
"setup_camera_3d": _setup_camera_3d,
|
||||
"add_gridmap": _add_gridmap,
|
||||
}
|
||||
|
||||
|
||||
## ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
func _optional_float(params: Dictionary, key: String, default: float) -> float:
|
||||
if params.has(key):
|
||||
return float(params[key])
|
||||
return default
|
||||
|
||||
|
||||
func _parse_color_param(params: Dictionary, key: String, default: Color) -> Color:
|
||||
if not params.has(key):
|
||||
return default
|
||||
var val: Variant = params[key]
|
||||
if val is String:
|
||||
return PropertyParser.parse_value(val, TYPE_COLOR)
|
||||
if val is Dictionary:
|
||||
return Color(
|
||||
float(val.get("r", default.r)),
|
||||
float(val.get("g", default.g)),
|
||||
float(val.get("b", default.b)),
|
||||
float(val.get("a", default.a))
|
||||
)
|
||||
return default
|
||||
|
||||
|
||||
func _parse_vector3_param(params: Dictionary, key: String, default: Vector3) -> Vector3:
|
||||
if not params.has(key):
|
||||
return default
|
||||
var val: Variant = params[key]
|
||||
if val is String:
|
||||
return PropertyParser.parse_value(val, TYPE_VECTOR3)
|
||||
if val is Dictionary:
|
||||
return Vector3(
|
||||
float(val.get("x", default.x)),
|
||||
float(val.get("y", default.y)),
|
||||
float(val.get("z", default.z))
|
||||
)
|
||||
if val is Array and val.size() >= 3:
|
||||
return Vector3(float(val[0]), float(val[1]), float(val[2]))
|
||||
return default
|
||||
|
||||
|
||||
func _add_child_with_undo(node: Node, parent: Node, root: Node, action_name: String) -> void:
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action(action_name)
|
||||
undo_redo.add_do_method(parent, "add_child", node)
|
||||
undo_redo.add_do_method(node, "set_owner", root)
|
||||
undo_redo.add_do_reference(node)
|
||||
undo_redo.add_undo_method(parent, "remove_child", node)
|
||||
undo_redo.commit_action()
|
||||
|
||||
|
||||
## ─── 1. add_mesh_instance ──────────────────────────────────────────────────
|
||||
|
||||
func _add_mesh_instance(params: Dictionary) -> Dictionary:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var parent_path: String = optional_string(params, "parent_path", ".")
|
||||
var parent := find_node_by_path(parent_path)
|
||||
if parent == null:
|
||||
return error_not_found("Parent node '%s'" % parent_path)
|
||||
|
||||
var node_name: String = optional_string(params, "name", "MeshInstance3D")
|
||||
var mesh_type: String = optional_string(params, "mesh_type", "")
|
||||
var mesh_file: String = optional_string(params, "mesh_file", "")
|
||||
|
||||
if mesh_type.is_empty() and mesh_file.is_empty():
|
||||
return error_invalid_params("Either 'mesh_type' or 'mesh_file' is required")
|
||||
|
||||
var mesh_instance := MeshInstance3D.new()
|
||||
mesh_instance.name = node_name
|
||||
|
||||
if not mesh_file.is_empty():
|
||||
# Load .glb / .gltf / .obj
|
||||
if not ResourceLoader.exists(mesh_file):
|
||||
mesh_instance.queue_free()
|
||||
return error_not_found("Mesh file '%s'" % mesh_file, "Provide a valid res:// path to .glb, .gltf, or .obj")
|
||||
var loaded: Resource = load(mesh_file)
|
||||
if loaded is Mesh:
|
||||
mesh_instance.mesh = loaded as Mesh
|
||||
elif loaded is PackedScene:
|
||||
# For .glb/.gltf we instantiate and steal the first MeshInstance3D's mesh
|
||||
var scene_instance: Node = (loaded as PackedScene).instantiate()
|
||||
var found_mesh: Mesh = null
|
||||
var search_nodes: Array[Node] = [scene_instance]
|
||||
while not search_nodes.is_empty():
|
||||
var n: Node = search_nodes.pop_front()
|
||||
if n is MeshInstance3D and (n as MeshInstance3D).mesh != null:
|
||||
found_mesh = (n as MeshInstance3D).mesh
|
||||
break
|
||||
for child in n.get_children():
|
||||
search_nodes.append(child)
|
||||
scene_instance.queue_free()
|
||||
if found_mesh == null:
|
||||
mesh_instance.queue_free()
|
||||
return error_invalid_params("No mesh found in '%s'" % mesh_file)
|
||||
mesh_instance.mesh = found_mesh
|
||||
else:
|
||||
mesh_instance.queue_free()
|
||||
return error_invalid_params("'%s' is not a Mesh or PackedScene" % mesh_file)
|
||||
else:
|
||||
# Primitive mesh
|
||||
var mesh_classes := {
|
||||
"BoxMesh": BoxMesh,
|
||||
"SphereMesh": SphereMesh,
|
||||
"CylinderMesh": CylinderMesh,
|
||||
"CapsuleMesh": CapsuleMesh,
|
||||
"PlaneMesh": PlaneMesh,
|
||||
"PrismMesh": PrismMesh,
|
||||
"TorusMesh": TorusMesh,
|
||||
"QuadMesh": QuadMesh,
|
||||
}
|
||||
if not mesh_classes.has(mesh_type):
|
||||
mesh_instance.queue_free()
|
||||
return error_invalid_params("Unknown mesh_type '%s'. Available: %s" % [mesh_type, mesh_classes.keys()])
|
||||
var mesh_res: Mesh = mesh_classes[mesh_type].new()
|
||||
# Apply mesh properties if provided
|
||||
var mesh_properties: Dictionary = params.get("mesh_properties", {})
|
||||
for prop_name: String in mesh_properties:
|
||||
if prop_name in mesh_res:
|
||||
var current: Variant = mesh_res.get(prop_name)
|
||||
mesh_res.set(prop_name, PropertyParser.parse_value(mesh_properties[prop_name], typeof(current)))
|
||||
mesh_instance.mesh = mesh_res
|
||||
|
||||
# Transform
|
||||
var position := _parse_vector3_param(params, "position", Vector3.ZERO)
|
||||
var rotation_deg := _parse_vector3_param(params, "rotation", Vector3.ZERO)
|
||||
var scale_vec := _parse_vector3_param(params, "scale", Vector3.ONE)
|
||||
|
||||
mesh_instance.position = position
|
||||
mesh_instance.rotation_degrees = rotation_deg
|
||||
mesh_instance.scale = scale_vec
|
||||
|
||||
_add_child_with_undo(mesh_instance, parent, root, "MCP: Add MeshInstance3D")
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(mesh_instance)),
|
||||
"name": str(mesh_instance.name),
|
||||
"mesh_type": mesh_type if mesh_file.is_empty() else mesh_file,
|
||||
})
|
||||
|
||||
|
||||
## ─── 2. setup_lighting ────────────────────────────────────────────────────
|
||||
|
||||
func _setup_lighting(params: Dictionary) -> Dictionary:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var parent_path: String = optional_string(params, "parent_path", ".")
|
||||
var parent := find_node_by_path(parent_path)
|
||||
if parent == null:
|
||||
return error_not_found("Parent node '%s'" % parent_path)
|
||||
|
||||
var light_type: String = optional_string(params, "light_type", "")
|
||||
var preset: String = optional_string(params, "preset", "")
|
||||
var node_name: String = optional_string(params, "name", "")
|
||||
|
||||
# Preset configurations
|
||||
if not preset.is_empty():
|
||||
match preset:
|
||||
"sun":
|
||||
light_type = "DirectionalLight3D"
|
||||
if node_name.is_empty():
|
||||
node_name = "SunLight"
|
||||
"indoor":
|
||||
light_type = "OmniLight3D"
|
||||
if node_name.is_empty():
|
||||
node_name = "IndoorLight"
|
||||
"dramatic":
|
||||
light_type = "SpotLight3D"
|
||||
if node_name.is_empty():
|
||||
node_name = "DramaticLight"
|
||||
_:
|
||||
return error_invalid_params("Unknown preset '%s'. Available: sun, indoor, dramatic" % preset)
|
||||
|
||||
if light_type.is_empty():
|
||||
return error_invalid_params("Either 'light_type' or 'preset' is required")
|
||||
|
||||
var light: Light3D
|
||||
match light_type:
|
||||
"DirectionalLight3D":
|
||||
light = DirectionalLight3D.new()
|
||||
"OmniLight3D":
|
||||
light = OmniLight3D.new()
|
||||
"SpotLight3D":
|
||||
light = SpotLight3D.new()
|
||||
_:
|
||||
return error_invalid_params("Unknown light_type '%s'. Available: DirectionalLight3D, OmniLight3D, SpotLight3D" % light_type)
|
||||
|
||||
if node_name.is_empty():
|
||||
node_name = light_type
|
||||
light.name = node_name
|
||||
|
||||
# Common properties
|
||||
light.light_color = _parse_color_param(params, "color", Color.WHITE)
|
||||
light.light_energy = _optional_float(params, "energy", 1.0)
|
||||
light.shadow_enabled = optional_bool(params, "shadows", false)
|
||||
|
||||
# Type-specific properties
|
||||
if light is OmniLight3D:
|
||||
var omni: OmniLight3D = light as OmniLight3D
|
||||
omni.omni_range = _optional_float(params, "range", 5.0)
|
||||
omni.omni_attenuation = _optional_float(params, "attenuation", 1.0)
|
||||
elif light is SpotLight3D:
|
||||
var spot: SpotLight3D = light as SpotLight3D
|
||||
spot.spot_range = _optional_float(params, "range", 5.0)
|
||||
spot.spot_attenuation = _optional_float(params, "attenuation", 1.0)
|
||||
spot.spot_angle = _optional_float(params, "spot_angle", 45.0)
|
||||
spot.spot_angle_attenuation = _optional_float(params, "spot_angle_attenuation", 1.0)
|
||||
|
||||
# Apply preset defaults after type creation
|
||||
if not preset.is_empty():
|
||||
match preset:
|
||||
"sun":
|
||||
light.light_energy = _optional_float(params, "energy", 1.0)
|
||||
light.shadow_enabled = optional_bool(params, "shadows", true)
|
||||
light.rotation_degrees = _parse_vector3_param(params, "rotation", Vector3(-45, -30, 0))
|
||||
"indoor":
|
||||
light.light_energy = _optional_float(params, "energy", 0.8)
|
||||
light.light_color = _parse_color_param(params, "color", Color(1.0, 0.95, 0.85))
|
||||
if light is OmniLight3D:
|
||||
(light as OmniLight3D).omni_range = _optional_float(params, "range", 8.0)
|
||||
"dramatic":
|
||||
light.light_energy = _optional_float(params, "energy", 2.0)
|
||||
light.shadow_enabled = optional_bool(params, "shadows", true)
|
||||
if light is SpotLight3D:
|
||||
(light as SpotLight3D).spot_angle = _optional_float(params, "spot_angle", 25.0)
|
||||
(light as SpotLight3D).spot_range = _optional_float(params, "range", 10.0)
|
||||
|
||||
# Position / rotation
|
||||
light.position = _parse_vector3_param(params, "position", Vector3.ZERO)
|
||||
if params.has("rotation"):
|
||||
light.rotation_degrees = _parse_vector3_param(params, "rotation", light.rotation_degrees)
|
||||
|
||||
_add_child_with_undo(light, parent, root, "MCP: Add %s" % light_type)
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(light)),
|
||||
"name": str(light.name),
|
||||
"light_type": light_type,
|
||||
"preset": preset,
|
||||
})
|
||||
|
||||
|
||||
## ─── 3. set_material_3d ───────────────────────────────────────────────────
|
||||
|
||||
func _set_material_3d(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path)
|
||||
|
||||
if not node is MeshInstance3D:
|
||||
return error_invalid_params("Node '%s' is not a MeshInstance3D (is %s)" % [node_path, node.get_class()])
|
||||
|
||||
var mesh_inst: MeshInstance3D = node as MeshInstance3D
|
||||
var surface_index: int = optional_int(params, "surface_index", 0)
|
||||
|
||||
var mat := StandardMaterial3D.new()
|
||||
|
||||
# Albedo
|
||||
mat.albedo_color = _parse_color_param(params, "albedo_color", Color.WHITE)
|
||||
if params.has("albedo_texture"):
|
||||
var tex_path: String = params["albedo_texture"]
|
||||
if ResourceLoader.exists(tex_path):
|
||||
mat.albedo_texture = load(tex_path) as Texture2D
|
||||
|
||||
# PBR
|
||||
mat.metallic = _optional_float(params, "metallic", 0.0)
|
||||
mat.roughness = _optional_float(params, "roughness", 1.0)
|
||||
if params.has("metallic_texture"):
|
||||
var tex_path: String = params["metallic_texture"]
|
||||
if ResourceLoader.exists(tex_path):
|
||||
mat.metallic_texture = load(tex_path) as Texture2D
|
||||
if params.has("roughness_texture"):
|
||||
var tex_path: String = params["roughness_texture"]
|
||||
if ResourceLoader.exists(tex_path):
|
||||
mat.roughness_texture = load(tex_path) as Texture2D
|
||||
if params.has("normal_texture"):
|
||||
mat.normal_enabled = true
|
||||
var tex_path: String = params["normal_texture"]
|
||||
if ResourceLoader.exists(tex_path):
|
||||
mat.normal_texture = load(tex_path) as Texture2D
|
||||
|
||||
# Emission
|
||||
if params.has("emission") or params.has("emission_color"):
|
||||
mat.emission_enabled = true
|
||||
mat.emission = _parse_color_param(params, "emission", _parse_color_param(params, "emission_color", Color.BLACK))
|
||||
mat.emission_energy_multiplier = _optional_float(params, "emission_energy", 1.0)
|
||||
if params.has("emission_texture"):
|
||||
mat.emission_enabled = true
|
||||
var tex_path: String = params["emission_texture"]
|
||||
if ResourceLoader.exists(tex_path):
|
||||
mat.emission_texture = load(tex_path) as Texture2D
|
||||
|
||||
# Transparency
|
||||
if params.has("transparency"):
|
||||
var transparency_val: String = str(params["transparency"])
|
||||
match transparency_val.to_upper():
|
||||
"DISABLED", "0":
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_DISABLED
|
||||
"ALPHA", "1":
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||
"ALPHA_SCISSOR", "2":
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR
|
||||
"ALPHA_HASH", "3":
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_HASH
|
||||
"ALPHA_DEPTH_PRE_PASS", "4":
|
||||
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_DEPTH_PRE_PASS
|
||||
|
||||
# Cull mode
|
||||
if params.has("cull_mode"):
|
||||
var cull_val: String = str(params["cull_mode"])
|
||||
match cull_val.to_upper():
|
||||
"BACK", "0":
|
||||
mat.cull_mode = BaseMaterial3D.CULL_BACK
|
||||
"FRONT", "1":
|
||||
mat.cull_mode = BaseMaterial3D.CULL_FRONT
|
||||
"DISABLED", "2":
|
||||
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||
|
||||
# Apply
|
||||
var old_mat: Material = mesh_inst.get_surface_override_material(surface_index)
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set material on %s" % mesh_inst.name)
|
||||
undo_redo.add_do_method(mesh_inst, "set_surface_override_material", surface_index, mat)
|
||||
undo_redo.add_undo_method(mesh_inst, "set_surface_override_material", surface_index, old_mat)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(mesh_inst)),
|
||||
"surface_index": surface_index,
|
||||
"albedo_color": str(mat.albedo_color),
|
||||
"metallic": mat.metallic,
|
||||
"roughness": mat.roughness,
|
||||
})
|
||||
|
||||
|
||||
## ─── 4. setup_environment ─────────────────────────────────────────────────
|
||||
|
||||
func _setup_environment(params: Dictionary) -> Dictionary:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var parent_path: String = optional_string(params, "parent_path", ".")
|
||||
var parent := find_node_by_path(parent_path)
|
||||
if parent == null:
|
||||
return error_not_found("Parent node '%s'" % parent_path)
|
||||
|
||||
var node_name: String = optional_string(params, "name", "WorldEnvironment")
|
||||
|
||||
# Check if a WorldEnvironment already exists at the target
|
||||
var node_path: String = optional_string(params, "node_path", "")
|
||||
var world_env: WorldEnvironment = null
|
||||
var is_existing := false
|
||||
|
||||
if not node_path.is_empty():
|
||||
var existing := find_node_by_path(node_path)
|
||||
if existing != null and existing is WorldEnvironment:
|
||||
world_env = existing as WorldEnvironment
|
||||
is_existing = true
|
||||
|
||||
if world_env == null:
|
||||
world_env = WorldEnvironment.new()
|
||||
world_env.name = node_name
|
||||
|
||||
var env: Environment = world_env.environment
|
||||
if env == null:
|
||||
env = Environment.new()
|
||||
|
||||
# Background / Sky
|
||||
var bg_mode: String = optional_string(params, "background_mode", "sky")
|
||||
match bg_mode.to_lower():
|
||||
"sky":
|
||||
env.background_mode = Environment.BG_SKY
|
||||
"color":
|
||||
env.background_mode = Environment.BG_COLOR
|
||||
env.background_color = _parse_color_param(params, "background_color", Color(0.3, 0.3, 0.3))
|
||||
"canvas":
|
||||
env.background_mode = Environment.BG_CANVAS
|
||||
"clear_color":
|
||||
env.background_mode = Environment.BG_CLEAR_COLOR
|
||||
|
||||
# Procedural sky
|
||||
if params.has("sky") and params["sky"] is Dictionary:
|
||||
var sky_params: Dictionary = params["sky"]
|
||||
var sky_mat := ProceduralSkyMaterial.new()
|
||||
sky_mat.sky_top_color = _parse_color_param(sky_params, "sky_top_color", Color(0.385, 0.454, 0.55))
|
||||
sky_mat.sky_horizon_color = _parse_color_param(sky_params, "sky_horizon_color", Color(0.646, 0.654, 0.67))
|
||||
sky_mat.ground_bottom_color = _parse_color_param(sky_params, "ground_bottom_color", Color(0.2, 0.169, 0.133))
|
||||
sky_mat.ground_horizon_color = _parse_color_param(sky_params, "ground_horizon_color", Color(0.646, 0.654, 0.67))
|
||||
sky_mat.sun_angle_max = _optional_float(sky_params, "sun_angle_max", 30.0) if sky_params.has("sun_angle_max") else 30.0
|
||||
sky_mat.sky_curve = _optional_float(sky_params, "sky_curve", 0.15) if sky_params.has("sky_curve") else 0.15
|
||||
|
||||
var sky := Sky.new()
|
||||
sky.sky_material = sky_mat
|
||||
env.sky = sky
|
||||
env.background_mode = Environment.BG_SKY
|
||||
|
||||
# Ambient light
|
||||
if params.has("ambient_light_color"):
|
||||
env.ambient_light_color = _parse_color_param(params, "ambient_light_color", Color.WHITE)
|
||||
env.ambient_light_energy = _optional_float(params, "ambient_light_energy", 1.0) if params.has("ambient_light_energy") else env.ambient_light_energy
|
||||
if params.has("ambient_light_source"):
|
||||
var src: String = str(params["ambient_light_source"])
|
||||
match src.to_upper():
|
||||
"BACKGROUND", "0":
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_BG
|
||||
"DISABLED", "1":
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_DISABLED
|
||||
"COLOR", "2":
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||
"SKY", "3":
|
||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||
|
||||
# Tonemap
|
||||
if params.has("tonemap_mode"):
|
||||
var tm: String = str(params["tonemap_mode"])
|
||||
match tm.to_upper():
|
||||
"LINEAR", "0":
|
||||
env.tonemap_mode = Environment.TONE_MAPPER_LINEAR
|
||||
"REINHARDT", "1":
|
||||
env.tonemap_mode = Environment.TONE_MAPPER_REINHARDT
|
||||
"FILMIC", "2":
|
||||
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||
"ACES", "3":
|
||||
env.tonemap_mode = Environment.TONE_MAPPER_ACES
|
||||
"AGX", "4":
|
||||
env.tonemap_mode = 4 # Environment.TONE_MAPPER_AGX (Godot 4.4+)
|
||||
if params.has("tonemap_exposure"):
|
||||
env.tonemap_exposure = _optional_float(params, "tonemap_exposure", 1.0)
|
||||
if params.has("tonemap_white"):
|
||||
env.tonemap_white = _optional_float(params, "tonemap_white", 1.0)
|
||||
|
||||
# Fog
|
||||
if params.has("fog_enabled"):
|
||||
env.fog_enabled = optional_bool(params, "fog_enabled", false)
|
||||
if env.fog_enabled or params.has("fog_light_color"):
|
||||
env.fog_light_color = _parse_color_param(params, "fog_light_color", Color(0.518, 0.553, 0.608))
|
||||
env.fog_density = _optional_float(params, "fog_density", 0.01) if params.has("fog_density") else env.fog_density
|
||||
env.fog_light_energy = _optional_float(params, "fog_light_energy", 1.0) if params.has("fog_light_energy") else env.fog_light_energy
|
||||
|
||||
# Glow
|
||||
if params.has("glow_enabled"):
|
||||
env.glow_enabled = optional_bool(params, "glow_enabled", false)
|
||||
if env.glow_enabled:
|
||||
env.glow_intensity = _optional_float(params, "glow_intensity", 0.8) if params.has("glow_intensity") else env.glow_intensity
|
||||
env.glow_strength = _optional_float(params, "glow_strength", 1.0) if params.has("glow_strength") else env.glow_strength
|
||||
env.glow_bloom = _optional_float(params, "glow_bloom", 0.0) if params.has("glow_bloom") else env.glow_bloom
|
||||
|
||||
# SSAO
|
||||
if params.has("ssao_enabled"):
|
||||
env.ssao_enabled = optional_bool(params, "ssao_enabled", false)
|
||||
if env.ssao_enabled:
|
||||
env.ssao_radius = _optional_float(params, "ssao_radius", 1.0) if params.has("ssao_radius") else env.ssao_radius
|
||||
env.ssao_intensity = _optional_float(params, "ssao_intensity", 2.0) if params.has("ssao_intensity") else env.ssao_intensity
|
||||
|
||||
# SSR
|
||||
if params.has("ssr_enabled"):
|
||||
env.ssr_enabled = optional_bool(params, "ssr_enabled", false)
|
||||
if env.ssr_enabled:
|
||||
env.ssr_max_steps = optional_int(params, "ssr_max_steps", 64) if params.has("ssr_max_steps") else env.ssr_max_steps
|
||||
env.ssr_fade_in = _optional_float(params, "ssr_fade_in", 0.15) if params.has("ssr_fade_in") else env.ssr_fade_in
|
||||
env.ssr_fade_out = _optional_float(params, "ssr_fade_out", 2.0) if params.has("ssr_fade_out") else env.ssr_fade_out
|
||||
|
||||
# SDFGI
|
||||
if params.has("sdfgi_enabled"):
|
||||
env.sdfgi_enabled = optional_bool(params, "sdfgi_enabled", false)
|
||||
|
||||
world_env.environment = env
|
||||
|
||||
if not is_existing:
|
||||
_add_child_with_undo(world_env, parent, root, "MCP: Add WorldEnvironment")
|
||||
|
||||
var features: Array = []
|
||||
if env.fog_enabled: features.append("fog")
|
||||
if env.glow_enabled: features.append("glow")
|
||||
if env.ssao_enabled: features.append("ssao")
|
||||
if env.ssr_enabled: features.append("ssr")
|
||||
if env.sdfgi_enabled: features.append("sdfgi")
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(world_env)),
|
||||
"name": str(world_env.name),
|
||||
"background_mode": bg_mode,
|
||||
"features": features,
|
||||
"is_existing": is_existing,
|
||||
})
|
||||
|
||||
|
||||
## ─── 5. setup_camera_3d ──────────────────────────────────────────────────
|
||||
|
||||
func _setup_camera_3d(params: Dictionary) -> Dictionary:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var parent_path: String = optional_string(params, "parent_path", ".")
|
||||
var parent := find_node_by_path(parent_path)
|
||||
if parent == null:
|
||||
return error_not_found("Parent node '%s'" % parent_path)
|
||||
|
||||
# Check if we're configuring an existing camera
|
||||
var node_path: String = optional_string(params, "node_path", "")
|
||||
var camera: Camera3D = null
|
||||
var is_existing := false
|
||||
|
||||
if not node_path.is_empty():
|
||||
var existing := find_node_by_path(node_path)
|
||||
if existing != null and existing is Camera3D:
|
||||
camera = existing as Camera3D
|
||||
is_existing = true
|
||||
elif existing != null:
|
||||
return error_invalid_params("Node '%s' is not a Camera3D (is %s)" % [node_path, existing.get_class()])
|
||||
|
||||
if camera == null:
|
||||
camera = Camera3D.new()
|
||||
camera.name = optional_string(params, "name", "Camera3D")
|
||||
|
||||
# Projection
|
||||
var projection_str: String = optional_string(params, "projection", "")
|
||||
if not projection_str.is_empty():
|
||||
match projection_str.to_lower():
|
||||
"perspective", "0":
|
||||
camera.projection = Camera3D.PROJECTION_PERSPECTIVE
|
||||
"orthogonal", "orthographic", "1":
|
||||
camera.projection = Camera3D.PROJECTION_ORTHOGONAL
|
||||
"frustum", "2":
|
||||
camera.projection = Camera3D.PROJECTION_FRUSTUM
|
||||
|
||||
# Properties
|
||||
if params.has("fov"):
|
||||
camera.fov = _optional_float(params, "fov", 75.0)
|
||||
if params.has("size"):
|
||||
camera.size = _optional_float(params, "size", 1.0)
|
||||
if params.has("near"):
|
||||
camera.near = _optional_float(params, "near", 0.05)
|
||||
if params.has("far"):
|
||||
camera.far = _optional_float(params, "far", 4000.0)
|
||||
if params.has("cull_mask"):
|
||||
camera.cull_mask = optional_int(params, "cull_mask", 1048575)
|
||||
|
||||
# Make current
|
||||
camera.current = optional_bool(params, "current", false)
|
||||
|
||||
# Transform
|
||||
camera.position = _parse_vector3_param(params, "position", camera.position if is_existing else Vector3(0, 1, 3))
|
||||
if params.has("rotation"):
|
||||
camera.rotation_degrees = _parse_vector3_param(params, "rotation", camera.rotation_degrees)
|
||||
if params.has("look_at"):
|
||||
var target := _parse_vector3_param(params, "look_at", Vector3.ZERO)
|
||||
# We need to set position first, then use look_at
|
||||
camera.look_at(target)
|
||||
|
||||
# Environment override
|
||||
if params.has("environment_path"):
|
||||
var env_path: String = params["environment_path"]
|
||||
if ResourceLoader.exists(env_path):
|
||||
var env_res: Resource = load(env_path)
|
||||
if env_res is Environment:
|
||||
camera.environment = env_res as Environment
|
||||
|
||||
if not is_existing:
|
||||
_add_child_with_undo(camera, parent, root, "MCP: Add Camera3D")
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(camera)),
|
||||
"name": str(camera.name),
|
||||
"projection": "perspective" if camera.projection == Camera3D.PROJECTION_PERSPECTIVE else "orthogonal",
|
||||
"fov": camera.fov,
|
||||
"position": str(camera.position),
|
||||
"is_existing": is_existing,
|
||||
})
|
||||
|
||||
|
||||
## ─── 6. add_gridmap ──────────────────────────────────────────────────────
|
||||
|
||||
func _add_gridmap(params: Dictionary) -> Dictionary:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var parent_path: String = optional_string(params, "parent_path", ".")
|
||||
var parent := find_node_by_path(parent_path)
|
||||
if parent == null:
|
||||
return error_not_found("Parent node '%s'" % parent_path)
|
||||
|
||||
var node_name: String = optional_string(params, "name", "GridMap")
|
||||
|
||||
# Check for existing GridMap to configure
|
||||
var node_path: String = optional_string(params, "node_path", "")
|
||||
var gridmap: GridMap = null
|
||||
var is_existing := false
|
||||
|
||||
if not node_path.is_empty():
|
||||
var existing := find_node_by_path(node_path)
|
||||
if existing != null and existing is GridMap:
|
||||
gridmap = existing as GridMap
|
||||
is_existing = true
|
||||
elif existing != null:
|
||||
return error_invalid_params("Node '%s' is not a GridMap (is %s)" % [node_path, existing.get_class()])
|
||||
|
||||
if gridmap == null:
|
||||
gridmap = GridMap.new()
|
||||
gridmap.name = node_name
|
||||
|
||||
# Mesh library
|
||||
if params.has("mesh_library_path"):
|
||||
var lib_path: String = params["mesh_library_path"]
|
||||
if not ResourceLoader.exists(lib_path):
|
||||
if not is_existing:
|
||||
gridmap.queue_free()
|
||||
return error_not_found("MeshLibrary '%s'" % lib_path, "Provide a valid res:// path to a .meshlib or .tres file")
|
||||
var lib: Resource = load(lib_path)
|
||||
if lib is MeshLibrary:
|
||||
gridmap.mesh_library = lib as MeshLibrary
|
||||
else:
|
||||
if not is_existing:
|
||||
gridmap.queue_free()
|
||||
return error_invalid_params("'%s' is not a MeshLibrary" % lib_path)
|
||||
|
||||
# Cell size
|
||||
if params.has("cell_size"):
|
||||
gridmap.cell_size = _parse_vector3_param(params, "cell_size", Vector3(2, 2, 2))
|
||||
|
||||
# Position
|
||||
gridmap.position = _parse_vector3_param(params, "position", gridmap.position if is_existing else Vector3.ZERO)
|
||||
|
||||
if not is_existing:
|
||||
_add_child_with_undo(gridmap, parent, root, "MCP: Add GridMap")
|
||||
|
||||
# Set cells
|
||||
var cells: Array = params.get("cells", [])
|
||||
var cells_set: int = 0
|
||||
for cell in cells:
|
||||
if cell is Dictionary:
|
||||
var x: int = int(cell.get("x", 0))
|
||||
var y: int = int(cell.get("y", 0))
|
||||
var z: int = int(cell.get("z", 0))
|
||||
var item: int = int(cell.get("item", 0))
|
||||
var orientation: int = int(cell.get("orientation", 0))
|
||||
gridmap.set_cell_item(Vector3i(x, y, z), item, orientation)
|
||||
cells_set += 1
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(gridmap)),
|
||||
"name": str(gridmap.name),
|
||||
"cells_set": cells_set,
|
||||
"is_existing": is_existing,
|
||||
"has_mesh_library": gridmap.mesh_library != null,
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
const NodeUtils := preload("res://addons/godot_mcp/utils/node_utils.gd")
|
||||
const PropertyParser := preload("res://addons/godot_mcp/utils/property_parser.gd")
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_scene_tree": _get_scene_tree,
|
||||
"get_scene_file_content": _get_scene_file_content,
|
||||
"create_scene": _create_scene,
|
||||
"open_scene": _open_scene,
|
||||
"delete_scene": _delete_scene,
|
||||
"add_scene_instance": _add_scene_instance,
|
||||
"play_scene": _play_scene,
|
||||
"stop_scene": _stop_scene,
|
||||
"save_scene": _save_scene,
|
||||
"get_scene_exports": _get_scene_exports,
|
||||
}
|
||||
|
||||
|
||||
func _get_scene_tree(params: Dictionary) -> Dictionary:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var max_depth: int = optional_int(params, "max_depth", -1)
|
||||
var tree := NodeUtils.get_node_tree(root, max_depth)
|
||||
return success({"scene_path": root.scene_file_path, "tree": tree})
|
||||
|
||||
|
||||
func _get_scene_file_content(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Scene file '%s'" % path)
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Cannot read file: %s" % error_string(FileAccess.get_open_error()))
|
||||
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
return success({"path": path, "content": content, "size": content.length()})
|
||||
|
||||
|
||||
func _create_scene(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
var guard := guard_offline_scene_save(path)
|
||||
if not guard.is_empty():
|
||||
return guard
|
||||
|
||||
var root_type: String = optional_string(params, "root_type", "Node2D")
|
||||
var root_name: String = optional_string(params, "root_name", "")
|
||||
|
||||
# Validate root type exists
|
||||
if not ClassDB.class_exists(root_type):
|
||||
return error_invalid_params("Unknown node type: %s" % root_type)
|
||||
|
||||
# Create the scene
|
||||
var root: Node = ClassDB.instantiate(root_type)
|
||||
if root_name.is_empty():
|
||||
root_name = path.get_file().get_basename()
|
||||
root.name = root_name
|
||||
|
||||
var scene := PackedScene.new()
|
||||
var err := scene.pack(root)
|
||||
root.queue_free()
|
||||
|
||||
if err != OK:
|
||||
return error_internal("Failed to pack scene: %s" % error_string(err))
|
||||
|
||||
# Ensure directory exists
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
|
||||
err = ResourceSaver.save(scene, path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to save scene: %s" % error_string(err))
|
||||
|
||||
# Refresh filesystem
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
|
||||
return success({"path": path, "root_type": root_type, "root_name": root_name})
|
||||
|
||||
|
||||
func _open_scene(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Scene file '%s'" % path)
|
||||
|
||||
EditorInterface.open_scene_from_path(path)
|
||||
return success({"path": path, "opened": true})
|
||||
|
||||
|
||||
func _delete_scene(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Scene file '%s'" % path)
|
||||
|
||||
var err := DirAccess.remove_absolute(path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to delete scene: %s" % error_string(err))
|
||||
|
||||
# Also remove .import file if exists
|
||||
var import_path := path + ".import"
|
||||
if FileAccess.file_exists(import_path):
|
||||
DirAccess.remove_absolute(import_path)
|
||||
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
return success({"path": path, "deleted": true})
|
||||
|
||||
|
||||
func _add_scene_instance(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "scene_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var scene_path: String = result[0]
|
||||
|
||||
var parent_path: String = optional_string(params, "parent_path", ".")
|
||||
var instance_name: String = optional_string(params, "name", "")
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
if not FileAccess.file_exists(scene_path):
|
||||
return error_not_found("Scene file '%s'" % scene_path)
|
||||
|
||||
var parent := find_node_by_path(parent_path)
|
||||
if parent == null:
|
||||
return error_not_found("Parent node '%s'" % parent_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
var packed: PackedScene = load(scene_path)
|
||||
if packed == null:
|
||||
return error_internal("Failed to load scene: %s" % scene_path)
|
||||
|
||||
var instance := packed.instantiate()
|
||||
if not instance_name.is_empty():
|
||||
instance.name = instance_name
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Add scene instance")
|
||||
undo_redo.add_do_method(parent, "add_child", instance)
|
||||
undo_redo.add_do_method(instance, "set_owner", root)
|
||||
undo_redo.add_do_reference(instance)
|
||||
undo_redo.add_undo_method(parent, "remove_child", instance)
|
||||
undo_redo.commit_action()
|
||||
|
||||
NodeUtils.set_owner_recursive(instance, root)
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(instance)),
|
||||
"scene_path": scene_path,
|
||||
"name": instance.name,
|
||||
})
|
||||
|
||||
|
||||
func _play_scene(params: Dictionary) -> Dictionary:
|
||||
var mode: String = optional_string(params, "mode", "main") # "main", "current", or path
|
||||
|
||||
match mode:
|
||||
"main":
|
||||
EditorInterface.play_main_scene()
|
||||
"current":
|
||||
EditorInterface.play_current_scene()
|
||||
_:
|
||||
# Treat as scene path
|
||||
if not FileAccess.file_exists(mode):
|
||||
return error_not_found("Scene file '%s'" % mode)
|
||||
EditorInterface.play_custom_scene(mode)
|
||||
|
||||
return success({"playing": true, "mode": mode})
|
||||
|
||||
|
||||
func _stop_scene(_params: Dictionary) -> Dictionary:
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return success({"stopped": false, "message": "No scene is currently playing"})
|
||||
|
||||
EditorInterface.stop_playing_scene()
|
||||
|
||||
# Clean up temp files
|
||||
_cleanup_screenshot_files()
|
||||
_cleanup_input_files()
|
||||
_cleanup_inspector_files()
|
||||
|
||||
return success({"stopped": true})
|
||||
|
||||
|
||||
func _save_scene(params: Dictionary) -> Dictionary:
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var path: String = optional_string(params, "path", "")
|
||||
if path.is_empty():
|
||||
path = root.scene_file_path
|
||||
|
||||
if path.is_empty():
|
||||
return error_invalid_params("No save path specified and scene has no existing path")
|
||||
|
||||
var normalized_path := normalize_project_path(path)
|
||||
if is_scene_path_open(normalized_path) and not is_active_scene_path(normalized_path):
|
||||
return error_conflict(
|
||||
"Refusing to save inactive open scene '%s' from the active editor scene" % normalized_path,
|
||||
{
|
||||
"path": normalized_path,
|
||||
"active_scene": normalize_project_path(root.scene_file_path),
|
||||
"open_scenes": get_open_scene_paths(),
|
||||
"suggestion": "Open the target scene tab before saving it, or close it before offline edits.",
|
||||
}
|
||||
)
|
||||
|
||||
var dir_path := normalized_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
|
||||
var err: int
|
||||
var save_method: String
|
||||
if root.scene_file_path.is_empty() or normalize_project_path(root.scene_file_path) != normalized_path:
|
||||
EditorInterface.save_scene_as(normalized_path)
|
||||
err = OK
|
||||
save_method = "EditorInterface.save_scene_as"
|
||||
else:
|
||||
err = EditorInterface.save_scene()
|
||||
save_method = "EditorInterface.save_scene"
|
||||
if err != OK:
|
||||
return error_internal("Failed to save scene via %s: %s" % [save_method, error_string(err)])
|
||||
|
||||
return success({"path": normalized_path, "saved": true, "method": save_method})
|
||||
|
||||
|
||||
func _get_scene_exports(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Scene file '%s'" % path)
|
||||
|
||||
var packed: PackedScene = load(path)
|
||||
if packed == null:
|
||||
return error_internal("Failed to load scene: %s" % path)
|
||||
|
||||
var instance: Node = packed.instantiate()
|
||||
if instance == null:
|
||||
return error_internal("Failed to instantiate scene: %s" % path)
|
||||
|
||||
var nodes_data: Array = []
|
||||
_collect_exports_recursive(instance, instance, nodes_data)
|
||||
|
||||
instance.queue_free()
|
||||
|
||||
return success({
|
||||
"path": path,
|
||||
"nodes": nodes_data,
|
||||
"count": nodes_data.size(),
|
||||
})
|
||||
|
||||
|
||||
func _collect_exports_recursive(node: Node, root: Node, nodes_data: Array) -> void:
|
||||
var script: Script = node.get_script()
|
||||
if script != null:
|
||||
var exports: Dictionary = {}
|
||||
for prop_info in script.get_script_property_list():
|
||||
var usage: int = prop_info["usage"]
|
||||
if (usage & PROPERTY_USAGE_EDITOR) and (usage & PROPERTY_USAGE_SCRIPT_VARIABLE):
|
||||
var prop_name: String = prop_info["name"]
|
||||
exports[prop_name] = {
|
||||
"value": PropertyParser.serialize_value(node.get(prop_name)),
|
||||
"type": prop_info["type"],
|
||||
"hint": prop_info.get("hint", 0),
|
||||
"hint_string": prop_info.get("hint_string", ""),
|
||||
}
|
||||
if not exports.is_empty():
|
||||
var node_path := "." if node == root else str(root.get_path_to(node))
|
||||
nodes_data.append({
|
||||
"node_path": node_path,
|
||||
"node_name": node.name,
|
||||
"node_type": node.get_class(),
|
||||
"script_path": script.resource_path,
|
||||
"exports": exports,
|
||||
})
|
||||
|
||||
for child in node.get_children():
|
||||
_collect_exports_recursive(child, root, nodes_data)
|
||||
|
||||
|
||||
func _cleanup_screenshot_files() -> void:
|
||||
var user_dir := get_game_user_dir()
|
||||
var request_path := user_dir + "/mcp_screenshot_request"
|
||||
var screenshot_path := user_dir + "/mcp_screenshot.png"
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
if FileAccess.file_exists(screenshot_path):
|
||||
DirAccess.remove_absolute(screenshot_path)
|
||||
|
||||
|
||||
func _cleanup_input_files() -> void:
|
||||
var user_dir := get_game_user_dir()
|
||||
var commands_path := user_dir + "/mcp_input_commands"
|
||||
if FileAccess.file_exists(commands_path):
|
||||
DirAccess.remove_absolute(commands_path)
|
||||
|
||||
|
||||
func _cleanup_inspector_files() -> void:
|
||||
var user_dir := get_game_user_dir()
|
||||
var request_path := user_dir + "/mcp_game_request"
|
||||
var response_path := user_dir + "/mcp_game_response"
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
if FileAccess.file_exists(response_path):
|
||||
DirAccess.remove_absolute(response_path)
|
||||
@@ -0,0 +1,369 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"list_scripts": _list_scripts,
|
||||
"read_script": _read_script,
|
||||
"create_script": _create_script,
|
||||
"edit_script": _edit_script,
|
||||
"attach_script": _attach_script,
|
||||
"get_open_scripts": _get_open_scripts,
|
||||
"validate_script": _validate_script,
|
||||
}
|
||||
|
||||
|
||||
func _guard_script_file_path(path: String, operation: String) -> Dictionary:
|
||||
var ext := path.get_extension().to_lower()
|
||||
if ext in ["gd", "cs"]:
|
||||
return {}
|
||||
return error(
|
||||
-32602,
|
||||
"%s only supports script files (.gd, .cs): %s" % [operation, normalize_project_path(path)],
|
||||
{
|
||||
"path": normalize_project_path(path),
|
||||
"extension": ext,
|
||||
"suggestion": "Use scene commands for .tscn/.scn files and shader commands for shader resources.",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
func _list_scripts(params: Dictionary) -> Dictionary:
|
||||
var path: String = optional_string(params, "path", "res://")
|
||||
var recursive: bool = optional_bool(params, "recursive", true)
|
||||
|
||||
var scripts: Array = []
|
||||
_find_scripts(path, recursive, scripts)
|
||||
|
||||
return success({"scripts": scripts, "count": scripts.size()})
|
||||
|
||||
|
||||
func _find_scripts(path: String, recursive: bool, scripts: Array) -> void:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var file_name := dir.get_next()
|
||||
|
||||
while not file_name.is_empty():
|
||||
if file_name.begins_with("."):
|
||||
file_name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(file_name)
|
||||
|
||||
if dir.current_is_dir():
|
||||
if recursive:
|
||||
_find_scripts(full_path, recursive, scripts)
|
||||
elif file_name.get_extension() in ["gd", "cs", "gdshader"]:
|
||||
var info := {"path": full_path, "type": file_name.get_extension()}
|
||||
# Get basic file info
|
||||
var file := FileAccess.open(full_path, FileAccess.READ)
|
||||
if file:
|
||||
info["size"] = file.get_length()
|
||||
# Read first line for class/extends info
|
||||
var first_line := file.get_line().strip_edges()
|
||||
if first_line.begins_with("class_name "):
|
||||
info["class_name"] = first_line.substr(11).strip_edges()
|
||||
elif first_line.begins_with("extends "):
|
||||
info["extends"] = first_line.substr(8).strip_edges()
|
||||
file.close()
|
||||
scripts.append(info)
|
||||
|
||||
file_name = dir.get_next()
|
||||
|
||||
dir.list_dir_end()
|
||||
|
||||
|
||||
func _read_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Script '%s'" % path)
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
|
||||
|
||||
var content := file.get_as_text()
|
||||
var line_count := content.count("\n") + 1
|
||||
file.close()
|
||||
|
||||
return success({
|
||||
"path": path,
|
||||
"content": content,
|
||||
"line_count": line_count,
|
||||
"size": content.length(),
|
||||
})
|
||||
|
||||
|
||||
func _create_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
var path_guard := _guard_script_file_path(path, "create_script")
|
||||
if not path_guard.is_empty():
|
||||
return path_guard
|
||||
|
||||
var content: String = optional_string(params, "content", "")
|
||||
var base_class: String = optional_string(params, "extends", "Node")
|
||||
var class_name_str: String = optional_string(params, "class_name", "")
|
||||
var force: bool = optional_bool(params, "force", false)
|
||||
|
||||
var guard := guard_text_resource_write(path, force)
|
||||
if not guard.is_empty():
|
||||
return guard
|
||||
|
||||
# Generate template if no content provided
|
||||
if content.is_empty():
|
||||
var lines: PackedStringArray = []
|
||||
if not class_name_str.is_empty():
|
||||
lines.append("class_name %s" % class_name_str)
|
||||
lines.append("extends %s" % base_class)
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
lines.append("func _ready() -> void:")
|
||||
lines.append("\tpass")
|
||||
lines.append("")
|
||||
content = "\n".join(lines)
|
||||
|
||||
# Ensure directory exists
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return error_internal("Cannot create script: %s" % error_string(FileAccess.get_open_error()))
|
||||
|
||||
file.store_string(content)
|
||||
file.close()
|
||||
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
|
||||
# Pre-load so the script is available immediately
|
||||
if ResourceLoader.exists(path):
|
||||
var script = load(path)
|
||||
if script is Script:
|
||||
script.reload(true)
|
||||
|
||||
return success({"path": path, "created": true})
|
||||
|
||||
|
||||
func _edit_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
var path_guard := _guard_script_file_path(path, "edit_script")
|
||||
if not path_guard.is_empty():
|
||||
return path_guard
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Script '%s'" % path)
|
||||
|
||||
var force: bool = optional_bool(params, "force", false)
|
||||
var guard := guard_text_resource_write(path, force)
|
||||
if not guard.is_empty():
|
||||
return guard
|
||||
|
||||
# Read current content
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
|
||||
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var changes_made := 0
|
||||
|
||||
# Support search-and-replace
|
||||
if params.has("replacements") and params["replacements"] is Array:
|
||||
var replacements: Array = params["replacements"]
|
||||
for replacement in replacements:
|
||||
if replacement is Dictionary:
|
||||
var search: String = replacement.get("search", "")
|
||||
var replace: String = replacement.get("replace", "")
|
||||
if not search.is_empty():
|
||||
var use_regex: bool = replacement.get("regex", false)
|
||||
if use_regex:
|
||||
var regex := RegEx.new()
|
||||
var err := regex.compile(search)
|
||||
if err == OK:
|
||||
var new_content := regex.sub(content, replace, true)
|
||||
if new_content != content:
|
||||
content = new_content
|
||||
changes_made += 1
|
||||
else:
|
||||
if content.contains(search):
|
||||
content = content.replace(search, replace)
|
||||
changes_made += 1
|
||||
|
||||
# Support 1-based inclusive line range replacement
|
||||
elif params.has("content") and (params.has("start_line") or params.has("end_line")):
|
||||
if not params.has("start_line"):
|
||||
return error_invalid_params("start_line is required when end_line is provided")
|
||||
var start_line: int = int(params["start_line"])
|
||||
var end_line: int = int(params.get("end_line", start_line))
|
||||
var lines := content.split("\n")
|
||||
if start_line < 1:
|
||||
return error_invalid_params("start_line must be >= 1")
|
||||
if end_line < start_line:
|
||||
return error_invalid_params("end_line must be >= start_line")
|
||||
if start_line > lines.size():
|
||||
return error_invalid_params("start_line is beyond the end of the file")
|
||||
if end_line > lines.size():
|
||||
return error_invalid_params("end_line is beyond the end of the file")
|
||||
|
||||
var replacement_lines := str(params["content"]).split("\n")
|
||||
var start_index := start_line - 1
|
||||
var remove_count := end_line - start_line + 1
|
||||
for _i in range(remove_count):
|
||||
lines.remove_at(start_index)
|
||||
for i in range(replacement_lines.size()):
|
||||
lines.insert(start_index + i, replacement_lines[i])
|
||||
content = "\n".join(lines)
|
||||
changes_made = 1
|
||||
|
||||
# Support full content replacement
|
||||
elif params.has("content"):
|
||||
content = str(params["content"])
|
||||
changes_made = 1
|
||||
|
||||
# Support insert at line
|
||||
elif params.has("insert_at_line") and params.has("text"):
|
||||
var line_num: int = int(params["insert_at_line"])
|
||||
var text: String = str(params["text"])
|
||||
var lines := content.split("\n")
|
||||
line_num = clampi(line_num, 0, lines.size())
|
||||
lines.insert(line_num, text)
|
||||
content = "\n".join(lines)
|
||||
changes_made = 1
|
||||
|
||||
if changes_made == 0:
|
||||
return success({"path": path, "changes_made": 0, "message": "No changes applied"})
|
||||
|
||||
# Write back
|
||||
file = FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return error_internal("Cannot write script: %s" % error_string(FileAccess.get_open_error()))
|
||||
|
||||
file.store_string(content)
|
||||
file.close()
|
||||
|
||||
# Reload the script resource so the editor picks up changes immediately
|
||||
_reload_script(path)
|
||||
|
||||
return success({"path": path, "changes_made": changes_made})
|
||||
|
||||
|
||||
## Force-reload a script so the editor reflects disk changes immediately.
|
||||
func _reload_script(path: String) -> void:
|
||||
# First, trigger a filesystem scan so Godot knows the file changed
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
|
||||
# If the script is already loaded in memory, reload it
|
||||
if ResourceLoader.exists(path):
|
||||
var script = load(path)
|
||||
if script is Script:
|
||||
script.reload(true)
|
||||
|
||||
# If the script is open in the script editor, the reload above updates it.
|
||||
# But we also need to notify the editor to refresh its error indicators.
|
||||
EditorInterface.get_script_editor().notification(Control.NOTIFICATION_VISIBILITY_CHANGED)
|
||||
|
||||
|
||||
func _attach_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "script_path")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var script_path: String = result2[0]
|
||||
|
||||
var root := get_edited_root()
|
||||
if root == null:
|
||||
return error_no_scene()
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node '%s'" % node_path, "Use get_scene_tree to see available nodes")
|
||||
|
||||
if not FileAccess.file_exists(script_path):
|
||||
return error_not_found("Script '%s'" % script_path)
|
||||
|
||||
var script: Script = load(script_path)
|
||||
if script == null:
|
||||
return error_internal("Failed to load script: %s" % script_path)
|
||||
|
||||
var old_script: Variant = node.get_script()
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Attach script to %s" % node.name)
|
||||
undo_redo.add_do_method(node, "set_script", script)
|
||||
undo_redo.add_undo_method(node, "set_script", old_script)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({
|
||||
"node_path": str(root.get_path_to(node)),
|
||||
"script_path": script_path,
|
||||
"attached": true,
|
||||
})
|
||||
|
||||
|
||||
func _validate_script(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
var path_guard := _guard_script_file_path(path, "validate_script")
|
||||
if not path_guard.is_empty():
|
||||
return path_guard
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Script '%s'" % path)
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Cannot read script: %s" % error_string(FileAccess.get_open_error()))
|
||||
|
||||
var source_code := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var script := GDScript.new()
|
||||
script.source_code = source_code
|
||||
var err := script.reload()
|
||||
|
||||
if err == OK:
|
||||
return success({"path": path, "valid": true, "message": "Script compiles successfully"})
|
||||
|
||||
return success({
|
||||
"path": path,
|
||||
"valid": false,
|
||||
"error_code": err,
|
||||
"error_string": error_string(err),
|
||||
"message": "Compilation failed. Use get_output_log or get_editor_errors for details.",
|
||||
})
|
||||
|
||||
|
||||
func _get_open_scripts(_params: Dictionary) -> Dictionary:
|
||||
var script_editor := EditorInterface.get_script_editor()
|
||||
var open_scripts: Array = []
|
||||
|
||||
for script_base in script_editor.get_open_scripts():
|
||||
var info := {
|
||||
"path": script_base.resource_path,
|
||||
"type": script_base.get_class(),
|
||||
}
|
||||
open_scripts.append(info)
|
||||
|
||||
return success({"scripts": open_scripts, "count": open_scripts.size()})
|
||||
@@ -0,0 +1,239 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"create_shader": _create_shader,
|
||||
"read_shader": _read_shader,
|
||||
"edit_shader": _edit_shader,
|
||||
"assign_shader_material": _assign_shader_material,
|
||||
"set_shader_param": _set_shader_param,
|
||||
"get_shader_params": _get_shader_params,
|
||||
}
|
||||
|
||||
|
||||
func _create_shader(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
var content: String = optional_string(params, "content", "")
|
||||
var shader_type: String = optional_string(params, "shader_type", "spatial")
|
||||
var force: bool = optional_bool(params, "force", false)
|
||||
|
||||
var guard := guard_text_resource_write(path, force)
|
||||
if not guard.is_empty():
|
||||
return guard
|
||||
|
||||
if content.is_empty():
|
||||
match shader_type:
|
||||
"spatial":
|
||||
content = "shader_type spatial;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tALBEDO = vec3(1.0);\n}\n"
|
||||
"canvas_item":
|
||||
content = "shader_type canvas_item;\n\nvoid vertex() {\n\t// Called for every vertex\n}\n\nvoid fragment() {\n\t// Called for every pixel\n\tCOLOR = vec4(1.0);\n}\n"
|
||||
"particles":
|
||||
content = "shader_type particles;\n\nvoid start() {\n\t// Called when particle spawns\n}\n\nvoid process() {\n\t// Called every frame per particle\n}\n"
|
||||
"sky":
|
||||
content = "shader_type sky;\n\nvoid sky() {\n\tCOLOR = vec3(0.3, 0.5, 0.8);\n}\n"
|
||||
|
||||
# Ensure directory exists
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return error_internal("Cannot create shader: %s" % error_string(FileAccess.get_open_error()))
|
||||
|
||||
file.store_string(content)
|
||||
file.close()
|
||||
|
||||
_refresh_loaded_shader(path, content)
|
||||
|
||||
return success({"path": path, "shader_type": shader_type, "created": true})
|
||||
|
||||
|
||||
func _read_shader(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Shader '%s'" % path)
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Cannot read shader: %s" % error_string(FileAccess.get_open_error()))
|
||||
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
return success({"path": path, "content": content, "size": content.length()})
|
||||
|
||||
|
||||
func _refresh_loaded_shader(path: String, content: String) -> void:
|
||||
var normalized := normalize_project_path(path)
|
||||
if normalized.is_empty():
|
||||
return
|
||||
if ResourceLoader.has_cached(normalized):
|
||||
var shader := Shader.new()
|
||||
shader.code = content
|
||||
shader.take_over_path(normalized)
|
||||
shader.emit_changed()
|
||||
EditorInterface.get_resource_filesystem().update_file(normalized)
|
||||
|
||||
|
||||
func _edit_shader(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return error_not_found("Shader '%s'" % path)
|
||||
|
||||
var force: bool = optional_bool(params, "force", false)
|
||||
var guard := guard_text_resource_write(path, force)
|
||||
if not guard.is_empty():
|
||||
return guard
|
||||
|
||||
var changes_made := 0
|
||||
var content := ""
|
||||
|
||||
if params.has("content"):
|
||||
content = str(params["content"])
|
||||
changes_made = 1
|
||||
elif params.has("replacements") and params["replacements"] is Array:
|
||||
# Read current
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Cannot read shader")
|
||||
content = file.get_as_text()
|
||||
file.close()
|
||||
|
||||
for replacement in params["replacements"]:
|
||||
if replacement is Dictionary:
|
||||
var search: String = replacement.get("search", "")
|
||||
var replace: String = replacement.get("replace", "")
|
||||
if not search.is_empty() and content.contains(search):
|
||||
content = content.replace(search, replace)
|
||||
changes_made += 1
|
||||
|
||||
if changes_made > 0:
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return error_internal("Cannot write shader: %s" % error_string(FileAccess.get_open_error()))
|
||||
file.store_string(content)
|
||||
file.close()
|
||||
_refresh_loaded_shader(path, content)
|
||||
|
||||
return success({"path": path, "changes_made": changes_made})
|
||||
|
||||
|
||||
func _assign_shader_material(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "shader_path")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var shader_path: String = result2[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
|
||||
if not ResourceLoader.exists(shader_path):
|
||||
return error_not_found("Shader '%s'" % shader_path)
|
||||
|
||||
var shader: Shader = load(shader_path)
|
||||
if shader == null:
|
||||
return error_internal("Failed to load shader")
|
||||
|
||||
var material := ShaderMaterial.new()
|
||||
material.shader = shader
|
||||
|
||||
if node is CanvasItem:
|
||||
set_property_with_undo(node, "material", material, "MCP: Assign shader material")
|
||||
elif node is MeshInstance3D:
|
||||
set_property_with_undo(node, "material_override", material, "MCP: Assign shader material")
|
||||
else:
|
||||
# Try generic material property
|
||||
if "material" in node:
|
||||
set_property_with_undo(node, "material", material, "MCP: Assign shader material")
|
||||
else:
|
||||
return error_invalid_params("Node '%s' (%s) does not support materials" % [node_path, node.get_class()])
|
||||
|
||||
return success({"node_path": node_path, "shader_path": shader_path, "assigned": true})
|
||||
|
||||
|
||||
func _set_shader_param(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "param")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var param_name: String = result2[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
|
||||
var material: ShaderMaterial = null
|
||||
if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial:
|
||||
material = (node as CanvasItem).material
|
||||
elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial:
|
||||
material = (node as MeshInstance3D).material_override
|
||||
|
||||
if material == null:
|
||||
return error(-32000, "Node has no ShaderMaterial")
|
||||
|
||||
var value = params.get("value")
|
||||
if value is String:
|
||||
var s: String = value
|
||||
var expr := Expression.new()
|
||||
if expr.parse(s) == OK:
|
||||
var parsed = expr.execute()
|
||||
if parsed != null:
|
||||
value = parsed
|
||||
|
||||
material.set_shader_parameter(param_name, value)
|
||||
|
||||
return success({"node_path": node_path, "param": param_name, "value": str(value)})
|
||||
|
||||
|
||||
func _get_shader_params(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null:
|
||||
return error_not_found("Node at '%s'" % node_path)
|
||||
|
||||
var material: ShaderMaterial = null
|
||||
if node is CanvasItem and (node as CanvasItem).material is ShaderMaterial:
|
||||
material = (node as CanvasItem).material
|
||||
elif node is MeshInstance3D and (node as MeshInstance3D).material_override is ShaderMaterial:
|
||||
material = (node as MeshInstance3D).material_override
|
||||
|
||||
if material == null:
|
||||
return error(-32000, "Node has no ShaderMaterial")
|
||||
|
||||
var shader_params: Dictionary = {}
|
||||
for prop in material.get_property_list():
|
||||
var pname: String = prop["name"]
|
||||
if pname.begins_with("shader_parameter/"):
|
||||
var key := pname.substr(17)
|
||||
shader_params[key] = str(material.get(pname))
|
||||
|
||||
return success({"node_path": node_path, "params": shader_params})
|
||||
@@ -0,0 +1,580 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
## Test automation framework tools.
|
||||
## Editor-side orchestration + runtime assertions via file-based IPC.
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"run_test_scenario": _run_test_scenario,
|
||||
"assert_node_state": _assert_node_state,
|
||||
"assert_screen_text": _assert_screen_text,
|
||||
"run_stress_test": _run_stress_test,
|
||||
"get_test_report": _get_test_report,
|
||||
}
|
||||
|
||||
|
||||
# ── Internal test result accumulator ──────────────────────────────────────────
|
||||
|
||||
var _test_results: Array[Dictionary] = []
|
||||
|
||||
|
||||
# ── Commands ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func _run_test_scenario(params: Dictionary) -> Dictionary:
|
||||
## Execute a test scenario: optionally play a scene, run a sequence of steps
|
||||
## (input simulation, waits, assertions, screenshots), return pass/fail results.
|
||||
##
|
||||
## Steps array: [{type: "input"|"wait"|"assert"|"screenshot", ...params}]
|
||||
## - input: {type:"input", action:str, pressed:bool} or {type:"input", keycode:str}
|
||||
## - wait: {type:"wait", seconds:float} or {type:"wait", node_path:str, timeout:float}
|
||||
## - assert: {type:"assert", node_path:str, property:str, expected:val, operator:str}
|
||||
## - screenshot: {type:"screenshot"} — captures a frame for visual inspection
|
||||
|
||||
if not params.has("steps") or not params["steps"] is Array:
|
||||
return error_invalid_params("Missing required parameter: steps (Array)")
|
||||
|
||||
var steps: Array = params["steps"]
|
||||
if steps.is_empty():
|
||||
return error_invalid_params("Steps array is empty")
|
||||
|
||||
var scene_path: String = optional_string(params, "scene_path")
|
||||
var ei := get_editor()
|
||||
|
||||
# Play scene if requested
|
||||
if not scene_path.is_empty():
|
||||
if ei.is_playing_scene():
|
||||
ei.stop_playing_scene()
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
|
||||
if scene_path == "main":
|
||||
ei.play_main_scene()
|
||||
elif scene_path == "current":
|
||||
ei.play_current_scene()
|
||||
else:
|
||||
if not FileAccess.file_exists(scene_path):
|
||||
return error_not_found("Scene file '%s'" % scene_path)
|
||||
ei.play_custom_scene(scene_path)
|
||||
|
||||
# Wait for game to start
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
|
||||
# Verify game is running
|
||||
if not ei.is_playing_scene():
|
||||
return error(-32000, "No scene is currently playing", {
|
||||
"suggestion": "Provide scene_path or use play_scene first"
|
||||
})
|
||||
|
||||
var results: Array[Dictionary] = []
|
||||
var pass_count: int = 0
|
||||
var fail_count: int = 0
|
||||
var error_count: int = 0
|
||||
|
||||
for i in steps.size():
|
||||
var step: Dictionary = steps[i]
|
||||
if not step.has("type"):
|
||||
results.append({"step": i, "error": "Missing 'type' field"})
|
||||
error_count += 1
|
||||
continue
|
||||
|
||||
var step_type: String = str(step["type"])
|
||||
var step_result: Dictionary = {"step": i, "type": step_type}
|
||||
|
||||
match step_type:
|
||||
"input":
|
||||
var input_result := await _execute_input_step(step)
|
||||
step_result.merge(input_result)
|
||||
|
||||
"wait":
|
||||
var wait_result := await _execute_wait_step(step)
|
||||
step_result.merge(wait_result)
|
||||
|
||||
"assert":
|
||||
var assert_result := await _execute_assert_step(step)
|
||||
step_result.merge(assert_result)
|
||||
if assert_result.get("passed", false):
|
||||
pass_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
"screenshot":
|
||||
var screenshot_result := await _send_game_command("capture_frames", {
|
||||
"count": 1,
|
||||
"frame_interval": 1,
|
||||
"half_resolution": optional_bool(step, "half_resolution", true),
|
||||
}, 5.0)
|
||||
if screenshot_result.has("result"):
|
||||
step_result["captured"] = true
|
||||
else:
|
||||
step_result["captured"] = false
|
||||
step_result["error"] = "Screenshot capture failed"
|
||||
error_count += 1
|
||||
|
||||
_:
|
||||
step_result["error"] = "Unknown step type: %s" % step_type
|
||||
error_count += 1
|
||||
|
||||
results.append(step_result)
|
||||
|
||||
# Check if game crashed between steps
|
||||
if not ei.is_playing_scene():
|
||||
results.append({"step": i + 1, "error": "Game stopped unexpectedly"})
|
||||
error_count += 1
|
||||
break
|
||||
|
||||
var summary := {
|
||||
"total_steps": steps.size(),
|
||||
"completed_steps": results.size(),
|
||||
"assertions_passed": pass_count,
|
||||
"assertions_failed": fail_count,
|
||||
"errors": error_count,
|
||||
"all_passed": fail_count == 0 and error_count == 0,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
# Store results for get_test_report
|
||||
_test_results.append_array(results)
|
||||
|
||||
return success(summary)
|
||||
|
||||
|
||||
func _assert_node_state(params: Dictionary) -> Dictionary:
|
||||
## Assert a node's property equals expected value in the running game.
|
||||
## Supports operators: eq, neq, gt, lt, gte, lte, contains, type_is.
|
||||
## Returns pass/fail with actual value.
|
||||
|
||||
var path_result := require_string(params, "node_path")
|
||||
if path_result[1] != null:
|
||||
return path_result[1]
|
||||
|
||||
var prop_result := require_string(params, "property")
|
||||
if prop_result[1] != null:
|
||||
return prop_result[1]
|
||||
|
||||
if not params.has("expected"):
|
||||
return error_invalid_params("Missing required parameter: expected")
|
||||
|
||||
var operator: String = optional_string(params, "operator", "eq")
|
||||
var valid_operators := ["eq", "neq", "gt", "lt", "gte", "lte", "contains", "type_is"]
|
||||
if operator not in valid_operators:
|
||||
return error_invalid_params("Invalid operator '%s'. Valid: %s" % [operator, str(valid_operators)])
|
||||
|
||||
var result := await _send_game_command("assert_node_state", {
|
||||
"node_path": path_result[0],
|
||||
"property": prop_result[0],
|
||||
"expected": params["expected"],
|
||||
"operator": operator,
|
||||
}, 5.0)
|
||||
|
||||
# Store for test report
|
||||
if result.has("result"):
|
||||
_test_results.append(result["result"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func _assert_screen_text(params: Dictionary) -> Dictionary:
|
||||
## Assert that specific text is visible on screen.
|
||||
## Uses find_ui_elements internally to check all visible UI text.
|
||||
|
||||
var text_result := require_string(params, "text")
|
||||
if text_result[1] != null:
|
||||
return text_result[1]
|
||||
|
||||
var expected_text: String = text_result[0]
|
||||
var partial: bool = optional_bool(params, "partial", true)
|
||||
var case_sensitive: bool = optional_bool(params, "case_sensitive", true)
|
||||
|
||||
# Use find_ui_elements to get all visible UI text
|
||||
var ui_result := await _send_game_command("find_ui_elements", {})
|
||||
if ui_result.has("error"):
|
||||
return ui_result
|
||||
|
||||
var elements: Array = []
|
||||
if ui_result.has("result") and ui_result["result"].has("elements"):
|
||||
elements = ui_result["result"]["elements"]
|
||||
|
||||
var found := false
|
||||
var matched_element: Dictionary = {}
|
||||
var all_texts: Array[String] = []
|
||||
|
||||
for element: Dictionary in elements:
|
||||
var element_text: String = str(element.get("text", ""))
|
||||
if element_text.is_empty():
|
||||
continue
|
||||
all_texts.append(element_text)
|
||||
|
||||
var search_text := expected_text
|
||||
var compare_text := element_text
|
||||
if not case_sensitive:
|
||||
search_text = search_text.to_lower()
|
||||
compare_text = compare_text.to_lower()
|
||||
|
||||
if partial:
|
||||
if compare_text.contains(search_text):
|
||||
found = true
|
||||
matched_element = element
|
||||
break
|
||||
else:
|
||||
if compare_text == search_text:
|
||||
found = true
|
||||
matched_element = element
|
||||
break
|
||||
|
||||
var assertion := {
|
||||
"passed": found,
|
||||
"expected_text": expected_text,
|
||||
"partial": partial,
|
||||
"case_sensitive": case_sensitive,
|
||||
}
|
||||
|
||||
if found:
|
||||
assertion["matched_element"] = {
|
||||
"text": matched_element.get("text", ""),
|
||||
"type": matched_element.get("type", ""),
|
||||
"path": matched_element.get("path", ""),
|
||||
}
|
||||
else:
|
||||
assertion["visible_texts"] = all_texts
|
||||
|
||||
# Store for test report
|
||||
_test_results.append(assertion)
|
||||
|
||||
return success(assertion)
|
||||
|
||||
|
||||
func _run_stress_test(params: Dictionary) -> Dictionary:
|
||||
## Run rapid random inputs for N seconds and check for crashes.
|
||||
## Returns frame count, timing, and any errors from game output.
|
||||
|
||||
var duration: float = float(params.get("duration", 5.0))
|
||||
if duration <= 0 or duration > 60:
|
||||
return error_invalid_params("Duration must be between 0 and 60 seconds")
|
||||
|
||||
var ei := get_editor()
|
||||
if not ei.is_playing_scene():
|
||||
return error(-32000, "No scene is currently playing", {
|
||||
"suggestion": "Use play_scene first"
|
||||
})
|
||||
|
||||
# Record initial error count from log
|
||||
var initial_errors := _count_log_errors()
|
||||
|
||||
# Generate random input events
|
||||
var actions := ["ui_up", "ui_down", "ui_left", "ui_right", "ui_accept", "ui_cancel"]
|
||||
# Add common game actions if specified
|
||||
var custom_actions: Array = params.get("actions", [])
|
||||
for action in custom_actions:
|
||||
actions.append(str(action))
|
||||
|
||||
var events_sent: int = 0
|
||||
var start_time := Time.get_ticks_msec()
|
||||
var duration_ms := int(duration * 1000.0)
|
||||
|
||||
while Time.get_ticks_msec() - start_time < duration_ms:
|
||||
if not ei.is_playing_scene():
|
||||
var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0
|
||||
return success({
|
||||
"completed": false,
|
||||
"crashed": true,
|
||||
"elapsed_seconds": elapsed,
|
||||
"events_sent": events_sent,
|
||||
"error": "Game stopped during stress test",
|
||||
})
|
||||
|
||||
# Send a batch of random inputs
|
||||
var batch: Array = []
|
||||
for j in 3:
|
||||
var action_name: String = actions[randi() % actions.size()]
|
||||
batch.append({
|
||||
"type": "action",
|
||||
"action": action_name,
|
||||
"pressed": true,
|
||||
"strength": 1.0,
|
||||
})
|
||||
batch.append({
|
||||
"type": "action",
|
||||
"action": action_name,
|
||||
"pressed": false,
|
||||
"strength": 0.0,
|
||||
})
|
||||
|
||||
# Write input commands directly (same as input_commands)
|
||||
var json := JSON.stringify({
|
||||
"sequence_events": batch,
|
||||
"frame_delay": 1,
|
||||
})
|
||||
var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE)
|
||||
if file:
|
||||
file.store_string(json)
|
||||
file.close()
|
||||
events_sent += batch.size()
|
||||
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
|
||||
var elapsed := (Time.get_ticks_msec() - start_time) / 1000.0
|
||||
var final_errors := _count_log_errors()
|
||||
var new_errors := final_errors - initial_errors
|
||||
|
||||
# Check if game is still running
|
||||
var still_running := ei.is_playing_scene()
|
||||
|
||||
return success({
|
||||
"completed": true,
|
||||
"crashed": not still_running,
|
||||
"duration_seconds": elapsed,
|
||||
"events_sent": events_sent,
|
||||
"new_errors": new_errors,
|
||||
"game_still_running": still_running,
|
||||
})
|
||||
|
||||
|
||||
func _get_test_report(params: Dictionary) -> Dictionary:
|
||||
## Collect and format results from accumulated assertions into a test report.
|
||||
## Returns pass count, fail count, and detailed results.
|
||||
|
||||
var clear: bool = optional_bool(params, "clear", true)
|
||||
|
||||
var pass_count: int = 0
|
||||
var fail_count: int = 0
|
||||
var details: Array[Dictionary] = []
|
||||
|
||||
for result: Dictionary in _test_results:
|
||||
var passed: bool = result.get("passed", false)
|
||||
if passed:
|
||||
pass_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
details.append(result)
|
||||
|
||||
var report := {
|
||||
"total": _test_results.size(),
|
||||
"passed": pass_count,
|
||||
"failed": fail_count,
|
||||
"pass_rate": ("%.1f%%" % (100.0 * pass_count / _test_results.size())) if not _test_results.is_empty() else "N/A",
|
||||
"all_passed": fail_count == 0 and not _test_results.is_empty(),
|
||||
"details": details,
|
||||
}
|
||||
|
||||
if clear:
|
||||
_test_results.clear()
|
||||
|
||||
return success(report)
|
||||
|
||||
|
||||
# ── Step Executors (for run_test_scenario) ────────────────────────────────────
|
||||
|
||||
func _execute_input_step(step: Dictionary) -> Dictionary:
|
||||
## Execute an input step: simulate action or key press.
|
||||
var events: Array = []
|
||||
|
||||
if step.has("action"):
|
||||
var pressed: bool = step.get("pressed", true) as bool
|
||||
events.append({
|
||||
"type": "action",
|
||||
"action": str(step["action"]),
|
||||
"pressed": pressed,
|
||||
"strength": float(step.get("strength", 1.0)),
|
||||
})
|
||||
# Auto-release if pressed
|
||||
if pressed and step.get("auto_release", true):
|
||||
events.append({
|
||||
"type": "action",
|
||||
"action": str(step["action"]),
|
||||
"pressed": false,
|
||||
"strength": 0.0,
|
||||
})
|
||||
elif step.has("keycode"):
|
||||
var pressed: bool = step.get("pressed", true) as bool
|
||||
events.append({
|
||||
"type": "key",
|
||||
"keycode": str(step["keycode"]),
|
||||
"pressed": pressed,
|
||||
"shift": step.get("shift", false),
|
||||
"ctrl": step.get("ctrl", false),
|
||||
"alt": step.get("alt", false),
|
||||
})
|
||||
else:
|
||||
return {"error": "Input step requires 'action' or 'keycode'"}
|
||||
|
||||
var json := JSON.stringify({
|
||||
"sequence_events": events,
|
||||
"frame_delay": int(step.get("frame_delay", 1)),
|
||||
})
|
||||
var file := FileAccess.open("user://mcp_input_commands", FileAccess.WRITE)
|
||||
if file == null:
|
||||
return {"error": "Failed to write input commands"}
|
||||
file.store_string(json)
|
||||
file.close()
|
||||
|
||||
return {"sent": true, "event_count": events.size()}
|
||||
|
||||
|
||||
func _execute_wait_step(step: Dictionary) -> Dictionary:
|
||||
## Execute a wait step: wait for seconds or wait for a node to appear.
|
||||
if step.has("node_path"):
|
||||
var timeout: float = float(step.get("timeout", 5.0))
|
||||
var result := await _send_game_command("wait_for_node", {
|
||||
"node_path": str(step["node_path"]),
|
||||
"timeout": timeout,
|
||||
"poll_frames": int(step.get("poll_frames", 5)),
|
||||
}, timeout + 2.0)
|
||||
if result.has("error"):
|
||||
return {"error": "Wait for node failed: %s" % str(result["error"])}
|
||||
return {"waited_for": str(step["node_path"]), "found": true}
|
||||
else:
|
||||
var seconds: float = float(step.get("seconds", 1.0))
|
||||
await get_tree().create_timer(seconds).timeout
|
||||
return {"waited_seconds": seconds}
|
||||
|
||||
|
||||
func _execute_assert_step(step: Dictionary) -> Dictionary:
|
||||
## Execute an assertion step within a scenario.
|
||||
if step.has("text"):
|
||||
# Screen text assertion
|
||||
var ui_result := await _send_game_command("find_ui_elements", {})
|
||||
if ui_result.has("error"):
|
||||
return {"passed": false, "error": "Could not get UI elements"}
|
||||
|
||||
var elements: Array = []
|
||||
if ui_result.has("result") and ui_result["result"].has("elements"):
|
||||
elements = ui_result["result"]["elements"]
|
||||
|
||||
var expected_text: String = str(step["text"])
|
||||
var partial: bool = step.get("partial", true) as bool
|
||||
for element: Dictionary in elements:
|
||||
var element_text: String = str(element.get("text", ""))
|
||||
if partial and element_text.contains(expected_text):
|
||||
return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text}
|
||||
elif not partial and element_text == expected_text:
|
||||
return {"passed": true, "type": "screen_text", "expected": expected_text, "found_in": element_text}
|
||||
|
||||
return {"passed": false, "type": "screen_text", "expected": expected_text, "error": "Text not found on screen"}
|
||||
|
||||
elif step.has("node_path") and step.has("property"):
|
||||
# Node state assertion
|
||||
var result := await _send_game_command("assert_node_state", {
|
||||
"node_path": str(step["node_path"]),
|
||||
"property": str(step["property"]),
|
||||
"expected": step.get("expected", null),
|
||||
"operator": str(step.get("operator", "eq")),
|
||||
}, 5.0)
|
||||
if result.has("result"):
|
||||
return result["result"]
|
||||
elif result.has("error"):
|
||||
return {"passed": false, "error": str(result["error"])}
|
||||
return {"passed": false, "error": "Unknown assertion error"}
|
||||
|
||||
else:
|
||||
return {"passed": false, "error": "Assert step requires 'text' or 'node_path'+'property'"}
|
||||
|
||||
|
||||
# ── IPC Helper ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _send_game_command(command: String, params: Dictionary = {}, timeout_sec: float = 5.0) -> Dictionary:
|
||||
var ei := get_editor()
|
||||
if not ei.is_playing_scene():
|
||||
return error(-32000, "No scene is currently playing", {"suggestion": "Use play_scene first"})
|
||||
|
||||
var user_dir := get_game_user_dir()
|
||||
var request_path := user_dir + "/mcp_game_request"
|
||||
var response_path := user_dir + "/mcp_game_response"
|
||||
|
||||
# Clean stale response
|
||||
if FileAccess.file_exists(response_path):
|
||||
DirAccess.remove_absolute(response_path)
|
||||
|
||||
# Write request
|
||||
var request_data := JSON.stringify({"command": command, "params": params})
|
||||
var req := FileAccess.open(request_path, FileAccess.WRITE)
|
||||
if req == null:
|
||||
return error_internal("Could not create game request file")
|
||||
req.store_string(request_data)
|
||||
req.close()
|
||||
|
||||
# Poll for response
|
||||
var attempts := int(timeout_sec / 0.1)
|
||||
while attempts > 0:
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
if FileAccess.file_exists(response_path):
|
||||
break
|
||||
# Check if game is still running
|
||||
if not ei.is_playing_scene():
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return error(-32000, "Game stopped during command execution")
|
||||
attempts -= 1
|
||||
|
||||
if not FileAccess.file_exists(response_path):
|
||||
# Try to auto-resume the debugger
|
||||
if ei.is_playing_scene():
|
||||
_try_debugger_continue()
|
||||
for _retry in 20:
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
if FileAccess.file_exists(response_path):
|
||||
break
|
||||
|
||||
if not FileAccess.file_exists(response_path):
|
||||
if FileAccess.file_exists(request_path):
|
||||
DirAccess.remove_absolute(request_path)
|
||||
return error(-32000, "Game command timed out after %.1fs" % timeout_sec, {
|
||||
"suggestion": "Ensure the game is running and MCPGameInspector autoload is active",
|
||||
})
|
||||
|
||||
# Read response
|
||||
var file := FileAccess.open(response_path, FileAccess.READ)
|
||||
if file == null:
|
||||
return error_internal("Could not read game response file")
|
||||
var text := file.get_as_text()
|
||||
file.close()
|
||||
DirAccess.remove_absolute(response_path)
|
||||
|
||||
var parsed = JSON.parse_string(text)
|
||||
if parsed == null or not parsed is Dictionary:
|
||||
return error_internal("Invalid response JSON from game")
|
||||
|
||||
if parsed.has("error"):
|
||||
return error(-32000, str(parsed["error"]))
|
||||
|
||||
return success(parsed)
|
||||
|
||||
|
||||
## Press the debugger "Continue" button to resume a paused game process.
|
||||
func _try_debugger_continue() -> void:
|
||||
var base := EditorInterface.get_base_control()
|
||||
if base == null:
|
||||
return
|
||||
var queue: Array[Node] = [base]
|
||||
while not queue.is_empty():
|
||||
var node := queue.pop_front()
|
||||
if node.get_class() == "ScriptEditorDebugger":
|
||||
var inner: Array[Node] = [node]
|
||||
while not inner.is_empty():
|
||||
var n := inner.pop_front()
|
||||
if n is Button and n.tooltip_text == "Continue":
|
||||
n.emit_signal("pressed")
|
||||
push_warning("[MCP] Auto-resumed debugger after runtime error")
|
||||
return
|
||||
for c in n.get_children():
|
||||
inner.append(c)
|
||||
return
|
||||
for child in node.get_children():
|
||||
queue.append(child)
|
||||
|
||||
|
||||
# ── Utility ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func _count_log_errors() -> int:
|
||||
var count: int = 0
|
||||
var log_path := "user://logs/godot.log"
|
||||
if FileAccess.file_exists(log_path):
|
||||
var file := FileAccess.open(log_path, FileAccess.READ)
|
||||
if file:
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
var lines := content.split("\n")
|
||||
for line: String in lines:
|
||||
if line.contains("ERROR") or line.contains("SCRIPT ERROR"):
|
||||
count += 1
|
||||
return count
|
||||
@@ -0,0 +1,424 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"create_theme": _create_theme,
|
||||
"set_theme_color": _set_theme_color,
|
||||
"set_theme_constant": _set_theme_constant,
|
||||
"set_theme_font_size": _set_theme_font_size,
|
||||
"set_theme_stylebox": _set_theme_stylebox,
|
||||
"setup_control": _setup_control,
|
||||
"get_theme_info": _get_theme_info,
|
||||
}
|
||||
|
||||
|
||||
func _create_theme(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var path: String = result[0]
|
||||
|
||||
var theme := Theme.new()
|
||||
|
||||
# Optionally set default font size
|
||||
var font_size: int = optional_int(params, "default_font_size", 0)
|
||||
if font_size > 0:
|
||||
theme.default_font_size = font_size
|
||||
|
||||
var scene_guard := guard_offline_scene_save(path)
|
||||
if scene_guard != null:
|
||||
return scene_guard
|
||||
|
||||
var err := ResourceSaver.save(theme, path)
|
||||
if err != OK:
|
||||
return error_internal("Failed to save theme: %s" % error_string(err))
|
||||
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
return success({"path": path, "created": true})
|
||||
|
||||
|
||||
func _set_theme_color(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var color_name: String = result2[0]
|
||||
|
||||
var result3 := require_string(params, "color")
|
||||
if result3[1] != null:
|
||||
return result3[1]
|
||||
var color_str: String = result3[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null or not (node is Control):
|
||||
return error_not_found("Control node at '%s'" % node_path)
|
||||
|
||||
var control: Control = node
|
||||
var color := Color(color_str)
|
||||
|
||||
var theme_type: String = optional_string(params, "theme_type", "")
|
||||
if theme_type.is_empty():
|
||||
theme_type = control.get_class()
|
||||
|
||||
var had_old := control.has_theme_color_override(color_name)
|
||||
var old_value: Variant = control.get("theme_override_colors/" + color_name) if had_old else null
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set theme color override")
|
||||
undo_redo.add_do_method(control, "add_theme_color_override", color_name, color)
|
||||
undo_redo.add_undo_method(self, "_restore_theme_override", control, "color", color_name, had_old, old_value)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"node_path": node_path, "name": color_name, "color": color_str})
|
||||
|
||||
|
||||
func _set_theme_constant(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var const_name: String = result2[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null or not (node is Control):
|
||||
return error_not_found("Control node at '%s'" % node_path)
|
||||
|
||||
var control: Control = node
|
||||
var value: int = int(params.get("value", 0))
|
||||
|
||||
var had_old := control.has_theme_constant_override(const_name)
|
||||
var old_value: Variant = control.get("theme_override_constants/" + const_name) if had_old else null
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set theme constant override")
|
||||
undo_redo.add_do_method(control, "add_theme_constant_override", const_name, value)
|
||||
undo_redo.add_undo_method(self, "_restore_theme_override", control, "constant", const_name, had_old, old_value)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"node_path": node_path, "name": const_name, "value": value})
|
||||
|
||||
|
||||
func _set_theme_font_size(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var font_name: String = result2[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null or not (node is Control):
|
||||
return error_not_found("Control node at '%s'" % node_path)
|
||||
|
||||
var control: Control = node
|
||||
var size: int = int(params.get("size", 16))
|
||||
|
||||
var had_old := control.has_theme_font_size_override(font_name)
|
||||
var old_value: Variant = control.get("theme_override_font_sizes/" + font_name) if had_old else null
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set theme font size override")
|
||||
undo_redo.add_do_method(control, "add_theme_font_size_override", font_name, size)
|
||||
undo_redo.add_undo_method(self, "_restore_theme_override", control, "font_size", font_name, had_old, old_value)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"node_path": node_path, "name": font_name, "size": size})
|
||||
|
||||
|
||||
func _set_theme_stylebox(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var result2 := require_string(params, "name")
|
||||
if result2[1] != null:
|
||||
return result2[1]
|
||||
var style_name: String = result2[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null or not (node is Control):
|
||||
return error_not_found("Control node at '%s'" % node_path)
|
||||
|
||||
var control: Control = node
|
||||
|
||||
var stylebox := StyleBoxFlat.new()
|
||||
|
||||
var bg_color: String = optional_string(params, "bg_color", "")
|
||||
if not bg_color.is_empty():
|
||||
stylebox.bg_color = Color(bg_color)
|
||||
|
||||
var border_color: String = optional_string(params, "border_color", "")
|
||||
if not border_color.is_empty():
|
||||
stylebox.border_color = Color(border_color)
|
||||
|
||||
var border_width: int = optional_int(params, "border_width", 0)
|
||||
if border_width > 0:
|
||||
stylebox.border_width_left = border_width
|
||||
stylebox.border_width_top = border_width
|
||||
stylebox.border_width_right = border_width
|
||||
stylebox.border_width_bottom = border_width
|
||||
|
||||
var corner_radius: int = optional_int(params, "corner_radius", 0)
|
||||
if corner_radius > 0:
|
||||
stylebox.corner_radius_top_left = corner_radius
|
||||
stylebox.corner_radius_top_right = corner_radius
|
||||
stylebox.corner_radius_bottom_left = corner_radius
|
||||
stylebox.corner_radius_bottom_right = corner_radius
|
||||
|
||||
var padding: int = optional_int(params, "padding", 0)
|
||||
if padding > 0:
|
||||
stylebox.content_margin_left = padding
|
||||
stylebox.content_margin_top = padding
|
||||
stylebox.content_margin_right = padding
|
||||
stylebox.content_margin_bottom = padding
|
||||
|
||||
var had_old := control.has_theme_stylebox_override(style_name)
|
||||
var old_value: Variant = control.get("theme_override_styles/" + style_name) if had_old else null
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set theme stylebox override")
|
||||
undo_redo.add_do_method(control, "add_theme_stylebox_override", style_name, stylebox)
|
||||
undo_redo.add_do_reference(stylebox)
|
||||
undo_redo.add_undo_method(self, "_restore_theme_override", control, "stylebox", style_name, had_old, old_value)
|
||||
if old_value is Resource:
|
||||
undo_redo.add_undo_reference(old_value)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"node_path": node_path, "name": style_name, "type": "StyleBoxFlat"})
|
||||
|
||||
|
||||
func _setup_control(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null or not (node is Control):
|
||||
return error_not_found("Control node at '%s'" % node_path)
|
||||
|
||||
var control: Control = node
|
||||
var applied: Array = []
|
||||
var old_state := _capture_control_setup_state(control)
|
||||
var target: Control = control.duplicate() as Control
|
||||
|
||||
# Anchor preset
|
||||
var anchor_preset: String = optional_string(params, "anchor_preset", "")
|
||||
if not anchor_preset.is_empty():
|
||||
var preset_map := {
|
||||
"top_left": Control.PRESET_TOP_LEFT,
|
||||
"top_right": Control.PRESET_TOP_RIGHT,
|
||||
"bottom_left": Control.PRESET_BOTTOM_LEFT,
|
||||
"bottom_right": Control.PRESET_BOTTOM_RIGHT,
|
||||
"center_left": Control.PRESET_CENTER_LEFT,
|
||||
"center_top": Control.PRESET_CENTER_TOP,
|
||||
"center_right": Control.PRESET_CENTER_RIGHT,
|
||||
"center_bottom": Control.PRESET_CENTER_BOTTOM,
|
||||
"center": Control.PRESET_CENTER,
|
||||
"left_wide": Control.PRESET_LEFT_WIDE,
|
||||
"top_wide": Control.PRESET_TOP_WIDE,
|
||||
"right_wide": Control.PRESET_RIGHT_WIDE,
|
||||
"bottom_wide": Control.PRESET_BOTTOM_WIDE,
|
||||
"vcenter_wide": Control.PRESET_VCENTER_WIDE,
|
||||
"hcenter_wide": Control.PRESET_HCENTER_WIDE,
|
||||
"full_rect": Control.PRESET_FULL_RECT,
|
||||
}
|
||||
if preset_map.has(anchor_preset):
|
||||
target.set_anchors_and_offsets_preset(preset_map[anchor_preset])
|
||||
applied.append("anchor_preset=%s" % anchor_preset)
|
||||
|
||||
# Min size
|
||||
var min_size_str: String = optional_string(params, "min_size", "")
|
||||
if not min_size_str.is_empty():
|
||||
var expr := Expression.new()
|
||||
if expr.parse(min_size_str) == OK:
|
||||
var val = expr.execute()
|
||||
if val is Vector2:
|
||||
target.custom_minimum_size = val
|
||||
applied.append("min_size=%s" % min_size_str)
|
||||
|
||||
# Size flags horizontal
|
||||
var sf_h: String = optional_string(params, "size_flags_h", "")
|
||||
if not sf_h.is_empty():
|
||||
var flags_map := {
|
||||
"fill": Control.SIZE_FILL,
|
||||
"expand": Control.SIZE_EXPAND,
|
||||
"fill_expand": Control.SIZE_EXPAND_FILL,
|
||||
"shrink_center": Control.SIZE_SHRINK_CENTER,
|
||||
"shrink_end": Control.SIZE_SHRINK_END,
|
||||
}
|
||||
if flags_map.has(sf_h):
|
||||
target.size_flags_horizontal = flags_map[sf_h]
|
||||
applied.append("size_flags_h=%s" % sf_h)
|
||||
|
||||
# Size flags vertical
|
||||
var sf_v: String = optional_string(params, "size_flags_v", "")
|
||||
if not sf_v.is_empty():
|
||||
var flags_map := {
|
||||
"fill": Control.SIZE_FILL,
|
||||
"expand": Control.SIZE_EXPAND,
|
||||
"fill_expand": Control.SIZE_EXPAND_FILL,
|
||||
"shrink_center": Control.SIZE_SHRINK_CENTER,
|
||||
"shrink_end": Control.SIZE_SHRINK_END,
|
||||
}
|
||||
if flags_map.has(sf_v):
|
||||
target.size_flags_vertical = flags_map[sf_v]
|
||||
applied.append("size_flags_v=%s" % sf_v)
|
||||
|
||||
# Margins (for MarginContainer)
|
||||
if params.has("margins") and params["margins"] is Dictionary:
|
||||
var margins: Dictionary = params["margins"]
|
||||
if target is MarginContainer:
|
||||
if margins.has("left"):
|
||||
target.add_theme_constant_override("margin_left", int(margins["left"]))
|
||||
if margins.has("top"):
|
||||
target.add_theme_constant_override("margin_top", int(margins["top"]))
|
||||
if margins.has("right"):
|
||||
target.add_theme_constant_override("margin_right", int(margins["right"]))
|
||||
if margins.has("bottom"):
|
||||
target.add_theme_constant_override("margin_bottom", int(margins["bottom"]))
|
||||
applied.append("margins=%s" % str(margins))
|
||||
|
||||
# Separation (for VBox/HBoxContainer)
|
||||
if params.has("separation"):
|
||||
var sep: int = int(params["separation"])
|
||||
if target is BoxContainer:
|
||||
target.add_theme_constant_override("separation", sep)
|
||||
applied.append("separation=%d" % sep)
|
||||
|
||||
# Grow direction horizontal
|
||||
var grow_h: String = optional_string(params, "grow_h", "")
|
||||
if not grow_h.is_empty():
|
||||
var grow_map := {
|
||||
"begin": Control.GROW_DIRECTION_BEGIN,
|
||||
"end": Control.GROW_DIRECTION_END,
|
||||
"both": Control.GROW_DIRECTION_BOTH,
|
||||
}
|
||||
if grow_map.has(grow_h):
|
||||
target.grow_horizontal = grow_map[grow_h]
|
||||
applied.append("grow_h=%s" % grow_h)
|
||||
|
||||
# Grow direction vertical
|
||||
var grow_v: String = optional_string(params, "grow_v", "")
|
||||
if not grow_v.is_empty():
|
||||
var grow_map := {
|
||||
"begin": Control.GROW_DIRECTION_BEGIN,
|
||||
"end": Control.GROW_DIRECTION_END,
|
||||
"both": Control.GROW_DIRECTION_BOTH,
|
||||
}
|
||||
if grow_map.has(grow_v):
|
||||
target.grow_vertical = grow_map[grow_v]
|
||||
applied.append("grow_v=%s" % grow_v)
|
||||
|
||||
if not applied.is_empty():
|
||||
var new_state := _capture_control_setup_state(target)
|
||||
_register_control_setup_undo(control, old_state, new_state)
|
||||
target.free()
|
||||
return success({"node_path": node_path, "applied": applied, "count": applied.size()})
|
||||
|
||||
|
||||
func _restore_theme_override(control: Control, kind: String, override_name: String, had_old: bool, old_value: Variant) -> void:
|
||||
match kind:
|
||||
"color":
|
||||
if had_old:
|
||||
control.add_theme_color_override(override_name, old_value)
|
||||
else:
|
||||
control.remove_theme_color_override(override_name)
|
||||
"constant":
|
||||
if had_old:
|
||||
control.add_theme_constant_override(override_name, old_value)
|
||||
else:
|
||||
control.remove_theme_constant_override(override_name)
|
||||
"font_size":
|
||||
if had_old:
|
||||
control.add_theme_font_size_override(override_name, old_value)
|
||||
else:
|
||||
control.remove_theme_font_size_override(override_name)
|
||||
"stylebox":
|
||||
if had_old:
|
||||
control.add_theme_stylebox_override(override_name, old_value)
|
||||
else:
|
||||
control.remove_theme_stylebox_override(override_name)
|
||||
|
||||
|
||||
func _capture_control_setup_state(control: Control) -> Dictionary:
|
||||
var state := {"properties": {}, "theme_constants": {}}
|
||||
for property: String in [
|
||||
"anchor_left", "anchor_top", "anchor_right", "anchor_bottom",
|
||||
"offset_left", "offset_top", "offset_right", "offset_bottom",
|
||||
"custom_minimum_size", "size_flags_horizontal", "size_flags_vertical",
|
||||
"grow_horizontal", "grow_vertical",
|
||||
]:
|
||||
state["properties"][property] = control.get(property)
|
||||
for constant_name: String in ["margin_left", "margin_top", "margin_right", "margin_bottom", "separation"]:
|
||||
var had_override := control.has_theme_constant_override(constant_name)
|
||||
state["theme_constants"][constant_name] = {
|
||||
"had": had_override,
|
||||
"value": control.get("theme_override_constants/" + constant_name) if had_override else null,
|
||||
}
|
||||
return state
|
||||
|
||||
|
||||
func _register_control_setup_undo(control: Control, old_state: Dictionary, new_state: Dictionary) -> void:
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Setup Control")
|
||||
for property: String in new_state["properties"]:
|
||||
undo_redo.add_do_property(control, property, new_state["properties"][property])
|
||||
undo_redo.add_undo_property(control, property, old_state["properties"][property])
|
||||
for constant_name: String in new_state["theme_constants"]:
|
||||
var new_constant: Dictionary = new_state["theme_constants"][constant_name]
|
||||
var old_constant: Dictionary = old_state["theme_constants"][constant_name]
|
||||
undo_redo.add_do_method(self, "_restore_theme_override", control, "constant", constant_name, new_constant["had"], new_constant["value"])
|
||||
undo_redo.add_undo_method(self, "_restore_theme_override", control, "constant", constant_name, old_constant["had"], old_constant["value"])
|
||||
undo_redo.commit_action()
|
||||
|
||||
|
||||
func _get_theme_info(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var node := find_node_by_path(node_path)
|
||||
if node == null or not (node is Control):
|
||||
return error_not_found("Control node at '%s'" % node_path)
|
||||
|
||||
var control: Control = node
|
||||
var info := {"node_path": node_path, "class": control.get_class()}
|
||||
|
||||
# Check if node has a theme
|
||||
var theme := control.theme
|
||||
if theme:
|
||||
info["theme_path"] = theme.resource_path
|
||||
info["type_list"] = Array(theme.get_type_list())
|
||||
|
||||
# List overrides
|
||||
var overrides := {"colors": {}, "constants": {}, "font_sizes": {}, "styleboxes": {}}
|
||||
for prop in control.get_property_list():
|
||||
var pname: String = prop["name"]
|
||||
if pname.begins_with("theme_override_colors/"):
|
||||
var key := pname.substr(22)
|
||||
overrides["colors"][key] = "#" + (control.get(pname) as Color).to_html()
|
||||
elif pname.begins_with("theme_override_constants/"):
|
||||
var key := pname.substr(25)
|
||||
overrides["constants"][key] = control.get(pname)
|
||||
elif pname.begins_with("theme_override_font_sizes/"):
|
||||
var key := pname.substr(26)
|
||||
overrides["font_sizes"][key] = control.get(pname)
|
||||
elif pname.begins_with("theme_override_styles/"):
|
||||
var key := pname.substr(22)
|
||||
var style = control.get(pname)
|
||||
overrides["styleboxes"][key] = style.get_class() if style else null
|
||||
|
||||
info["overrides"] = overrides
|
||||
return success(info)
|
||||
@@ -0,0 +1,217 @@
|
||||
@tool
|
||||
extends "res://addons/godot_mcp/commands/base_command.gd"
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"tilemap_set_cell": _tilemap_set_cell,
|
||||
"tilemap_fill_rect": _tilemap_fill_rect,
|
||||
"tilemap_get_cell": _tilemap_get_cell,
|
||||
"tilemap_clear": _tilemap_clear,
|
||||
"tilemap_get_info": _tilemap_get_info,
|
||||
"tilemap_get_used_cells": _tilemap_get_used_cells,
|
||||
}
|
||||
|
||||
|
||||
func _find_tilemap(node_path: String) -> TileMapLayer:
|
||||
var node := find_node_by_path(node_path)
|
||||
if node is TileMapLayer:
|
||||
return node as TileMapLayer
|
||||
return null
|
||||
|
||||
|
||||
func _tilemap_set_cell(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var tilemap := _find_tilemap(node_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
var x: int = int(params.get("x", 0))
|
||||
var y: int = int(params.get("y", 0))
|
||||
var source_id: int = int(params.get("source_id", 0))
|
||||
var atlas_x: int = int(params.get("atlas_x", 0))
|
||||
var atlas_y: int = int(params.get("atlas_y", 0))
|
||||
var alternative: int = int(params.get("alternative", 0))
|
||||
|
||||
var coords := Vector2i(x, y)
|
||||
var old_cells := [_capture_cell(tilemap, coords)]
|
||||
var new_cells := [_make_cell(coords, source_id, Vector2i(atlas_x, atlas_y), alternative)]
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Set TileMap cell")
|
||||
_add_do_set_cells(undo_redo, tilemap, new_cells)
|
||||
_add_undo_set_cells(undo_redo, tilemap, old_cells)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"x": x, "y": y, "source_id": source_id, "atlas_coords": [atlas_x, atlas_y]})
|
||||
|
||||
|
||||
func _tilemap_fill_rect(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var tilemap := _find_tilemap(node_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
var x1: int = int(params.get("x1", 0))
|
||||
var y1: int = int(params.get("y1", 0))
|
||||
var x2: int = int(params.get("x2", 0))
|
||||
var y2: int = int(params.get("y2", 0))
|
||||
var source_id: int = int(params.get("source_id", 0))
|
||||
var atlas_x: int = int(params.get("atlas_x", 0))
|
||||
var atlas_y: int = int(params.get("atlas_y", 0))
|
||||
var alternative: int = int(params.get("alternative", 0))
|
||||
|
||||
var count := 0
|
||||
var old_cells: Array = []
|
||||
var new_cells: Array = []
|
||||
for cx in range(mini(x1, x2), maxi(x1, x2) + 1):
|
||||
for cy in range(mini(y1, y2), maxi(y1, y2) + 1):
|
||||
var coords := Vector2i(cx, cy)
|
||||
old_cells.append(_capture_cell(tilemap, coords))
|
||||
new_cells.append(_make_cell(coords, source_id, Vector2i(atlas_x, atlas_y), alternative))
|
||||
count += 1
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Fill TileMap rect")
|
||||
_add_do_set_cells(undo_redo, tilemap, new_cells)
|
||||
_add_undo_set_cells(undo_redo, tilemap, old_cells)
|
||||
undo_redo.commit_action()
|
||||
|
||||
return success({"filled": count, "rect": [x1, y1, x2, y2]})
|
||||
|
||||
|
||||
func _tilemap_get_cell(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var tilemap := _find_tilemap(node_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
var x: int = int(params.get("x", 0))
|
||||
var y: int = int(params.get("y", 0))
|
||||
var coords := Vector2i(x, y)
|
||||
|
||||
var source_id := tilemap.get_cell_source_id(coords)
|
||||
var atlas_coords := tilemap.get_cell_atlas_coords(coords)
|
||||
var alternative := tilemap.get_cell_alternative_tile(coords)
|
||||
|
||||
return success({
|
||||
"x": x, "y": y,
|
||||
"source_id": source_id,
|
||||
"atlas_coords": [atlas_coords.x, atlas_coords.y],
|
||||
"alternative": alternative,
|
||||
"empty": source_id == -1,
|
||||
})
|
||||
|
||||
|
||||
func _tilemap_clear(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var tilemap := _find_tilemap(node_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
var old_cells: Array = []
|
||||
for coords: Vector2i in tilemap.get_used_cells():
|
||||
old_cells.append(_capture_cell(tilemap, coords))
|
||||
|
||||
var undo_redo := get_undo_redo()
|
||||
undo_redo.create_action("MCP: Clear TileMap")
|
||||
undo_redo.add_do_method(tilemap, "clear")
|
||||
_add_undo_set_cells(undo_redo, tilemap, old_cells)
|
||||
undo_redo.commit_action()
|
||||
return success({"cleared": true})
|
||||
|
||||
|
||||
func _make_cell(coords: Vector2i, source_id: int, atlas_coords: Vector2i, alternative: int) -> Dictionary:
|
||||
return {
|
||||
"coords": coords,
|
||||
"source_id": source_id,
|
||||
"atlas_coords": atlas_coords,
|
||||
"alternative": alternative,
|
||||
}
|
||||
|
||||
|
||||
func _capture_cell(tilemap: TileMapLayer, coords: Vector2i) -> Dictionary:
|
||||
return _make_cell(
|
||||
coords,
|
||||
tilemap.get_cell_source_id(coords),
|
||||
tilemap.get_cell_atlas_coords(coords),
|
||||
tilemap.get_cell_alternative_tile(coords)
|
||||
)
|
||||
|
||||
|
||||
func _add_do_set_cells(undo_redo: EditorUndoRedoManager, tilemap: TileMapLayer, cells: Array) -> void:
|
||||
for cell: Dictionary in cells:
|
||||
undo_redo.add_do_method(tilemap, "set_cell", cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"])
|
||||
|
||||
|
||||
func _add_undo_set_cells(undo_redo: EditorUndoRedoManager, tilemap: TileMapLayer, cells: Array) -> void:
|
||||
for cell: Dictionary in cells:
|
||||
undo_redo.add_undo_method(tilemap, "set_cell", cell["coords"], cell["source_id"], cell["atlas_coords"], cell["alternative"])
|
||||
|
||||
|
||||
func _tilemap_get_info(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var tilemap := _find_tilemap(node_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
var tile_set := tilemap.tile_set
|
||||
var sources: Array = []
|
||||
if tile_set:
|
||||
for i in tile_set.get_source_count():
|
||||
var source_id := tile_set.get_source_id(i)
|
||||
var source := tile_set.get_source(source_id)
|
||||
var info := {"id": source_id, "type": source.get_class()}
|
||||
if source is TileSetAtlasSource:
|
||||
var atlas: TileSetAtlasSource = source
|
||||
info["texture"] = atlas.texture.resource_path if atlas.texture else ""
|
||||
info["tile_count"] = atlas.get_tiles_count()
|
||||
sources.append(info)
|
||||
|
||||
return success({
|
||||
"node_path": node_path,
|
||||
"used_cells": tilemap.get_used_cells().size(),
|
||||
"tile_set_sources": sources,
|
||||
"tile_size": [tile_set.tile_size.x, tile_set.tile_size.y] if tile_set else [0, 0],
|
||||
})
|
||||
|
||||
|
||||
func _tilemap_get_used_cells(params: Dictionary) -> Dictionary:
|
||||
var result := require_string(params, "node_path")
|
||||
if result[1] != null:
|
||||
return result[1]
|
||||
var node_path: String = result[0]
|
||||
|
||||
var tilemap := _find_tilemap(node_path)
|
||||
if tilemap == null:
|
||||
return error_not_found("TileMapLayer at '%s'" % node_path)
|
||||
|
||||
var max_count: int = optional_int(params, "max_count", 500)
|
||||
var cells: Array = []
|
||||
var used := tilemap.get_used_cells()
|
||||
|
||||
for i in mini(used.size(), max_count):
|
||||
var pos: Vector2i = used[i]
|
||||
cells.append({"x": pos.x, "y": pos.y, "source_id": tilemap.get_cell_source_id(pos)})
|
||||
|
||||
return success({"cells": cells, "total": used.size(), "returned": cells.size()})
|
||||
Reference in New Issue
Block a user