Fresh start: replace with naxIO/netfox-cs-sample foundation
Complete replacement of the tactical-shooter project with the netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built with Godot 4 and netfox. ## What's new - Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu - 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP - Bomb plant/defuse with 2 bombsites - Flashbang & smoke grenades - Proper netfox rollback netcode at 64 tick - Network popup UI for host/join - HUD, crosshair, round timer, scoreboard - All netfox singletons registered as autoloads (works in exported builds) ## Architecture - Listen-server (host from client, no dedicated server binary) - Multiplayer-fps game lives at examples/multiplayer-fps/ - Netfox addons registered as autoloads for exported build compat - Godot 4.7 with Forward+ renderer ## Removed - Old headless-server architecture (client_main, server_main, player.gd, etc.) - Custom netfox bootstrap with ENet fallback - Old ChaffGames FPS template (2,420 lines, 844 KB) - SimulationServer GDExtension stub - Godot-jolt physics (netfox sample uses default Godot physics) - Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc. - Server browser API Python venv (87 MB) - test_range map and modular assets ## Preserved - Git history - Server config at config/default_server_config.cfg - Windows export preset - Build directory (gitignored) Co-authored-by: naxIO <naxIO@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name AssetTools
|
||||
## Asset generation tools for MCP.
|
||||
## Handles: generate_2d_asset, search_comfyui_nodes,
|
||||
## inspect_runninghub_workflow, customize_and_run_workflow
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
func _refresh_filesystem() -> void:
|
||||
if _editor_plugin:
|
||||
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
|
||||
|
||||
# =============================================================================
|
||||
# generate_2d_asset - Generate PNG from SVG code
|
||||
# =============================================================================
|
||||
func generate_2d_asset(args: Dictionary) -> Dictionary:
|
||||
var svg_code: String = str(args.get(&"svg_code", ""))
|
||||
var filename: String = str(args.get(&"filename", ""))
|
||||
var save_path: String = str(args.get(&"save_path", "res://assets/generated/"))
|
||||
|
||||
if svg_code.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'svg_code'"}
|
||||
if filename.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'filename'"}
|
||||
|
||||
# Ensure .png extension
|
||||
if not filename.ends_with(".png"):
|
||||
filename += ".png"
|
||||
|
||||
# Ensure save path
|
||||
if not save_path.begins_with("res://"):
|
||||
save_path = "res://" + save_path
|
||||
if not save_path.ends_with("/"):
|
||||
save_path += "/"
|
||||
|
||||
# Create directory if needed
|
||||
if not DirAccess.dir_exists_absolute(save_path):
|
||||
DirAccess.make_dir_recursive_absolute(save_path)
|
||||
|
||||
# Parse SVG dimensions from the svg_code
|
||||
var width := 64
|
||||
var height := 64
|
||||
|
||||
# Simple regex-free parsing for width/height
|
||||
var w_start := svg_code.find("width=\"")
|
||||
if w_start != -1:
|
||||
var w_val := svg_code.substr(w_start + 7)
|
||||
var w_end := w_val.find("\"")
|
||||
if w_end != -1:
|
||||
width = int(w_val.substr(0, w_end))
|
||||
|
||||
var h_start := svg_code.find("height=\"")
|
||||
if h_start != -1:
|
||||
var h_val := svg_code.substr(h_start + 8)
|
||||
var h_end := h_val.find("\"")
|
||||
if h_end != -1:
|
||||
height = int(h_val.substr(0, h_end))
|
||||
|
||||
# Create Image from SVG
|
||||
var image := Image.new()
|
||||
|
||||
# Save SVG to temp file, then load as image
|
||||
var temp_svg_path := "user://temp_asset.svg"
|
||||
var svg_file := FileAccess.open(temp_svg_path, FileAccess.WRITE)
|
||||
if not svg_file:
|
||||
return {&"ok": false, &"error": "Failed to create temp SVG file"}
|
||||
svg_file.store_string(svg_code)
|
||||
svg_file.close()
|
||||
|
||||
# Load SVG as image
|
||||
var err := image.load(temp_svg_path)
|
||||
if err != OK:
|
||||
# Fallback: try loading SVG data directly
|
||||
image = Image.create(width, height, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color(1, 0, 1, 1)) # Magenta fallback = something went wrong
|
||||
print("[MCP] Warning: Could not render SVG, created fallback image")
|
||||
|
||||
# Clean up temp file
|
||||
DirAccess.remove_absolute(temp_svg_path)
|
||||
|
||||
# Save as PNG
|
||||
var full_path := save_path + filename
|
||||
var global_path := ProjectSettings.globalize_path(full_path)
|
||||
err = image.save_png(global_path)
|
||||
if err != OK:
|
||||
return {&"ok": false, &"error": "Failed to save PNG: " + str(err)}
|
||||
|
||||
_refresh_filesystem()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"resource_path": full_path,
|
||||
&"dimensions": {&"width": width, &"height": height},
|
||||
&"message": "Generated %s (%dx%d)" % [full_path, width, height],
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# search_comfyui_nodes - Stub (requires external database)
|
||||
# =============================================================================
|
||||
func search_comfyui_nodes(args: Dictionary) -> Dictionary:
|
||||
# This tool requires the ComfyUI node database which was bundled with the old plugin
|
||||
# For now, return a message indicating it needs setup
|
||||
return {
|
||||
&"ok": true,
|
||||
&"results": [],
|
||||
&"count": 0,
|
||||
&"message": "ComfyUI node search requires the node database. This feature will be available in a future update.",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# inspect_runninghub_workflow - Stub (requires API key)
|
||||
# =============================================================================
|
||||
func inspect_runninghub_workflow(args: Dictionary) -> Dictionary:
|
||||
var workflow_id: String = str(args.get(&"workflow_id", ""))
|
||||
if workflow_id.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'workflow_id'"}
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"workflow_id": workflow_id,
|
||||
&"message": "RunningHub workflow inspection requires API configuration. This feature will be available in a future update.",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# customize_and_run_workflow - Stub (requires API key)
|
||||
# =============================================================================
|
||||
func customize_and_run_workflow(args: Dictionary) -> Dictionary:
|
||||
return {
|
||||
&"ok": true,
|
||||
&"message": "RunningHub workflow execution requires API configuration. This feature will be available in a future update.",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://br1exnegw426j
|
||||
@@ -0,0 +1,292 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name FileTools
|
||||
## File operation tools for MCP.
|
||||
## Handles: list_dir, read_file, search_project, create_script
|
||||
|
||||
const DEFAULT_MAX_BYTES := 200_000
|
||||
const DEFAULT_MAX_RESULTS := 200
|
||||
const MAX_TRAVERSAL_DEPTH := 20
|
||||
const _SKIP_EXTENSIONS: Dictionary = {
|
||||
".import": true, ".png": true, ".jpg": true, ".jpeg": true,
|
||||
".webp": true, ".svg": true, ".ogg": true, ".wav": true,
|
||||
".mp3": true, ".escn": true, ".glb": true, ".gltf": true,
|
||||
".uid": true,
|
||||
}
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
# =============================================================================
|
||||
# list_dir - List files and folders in a directory
|
||||
# =============================================================================
|
||||
func list_dir(args: Dictionary) -> Dictionary:
|
||||
var root: String = str(args.get(&"root", "res://"))
|
||||
var include_hidden: bool = bool(args.get(&"include_hidden", false))
|
||||
|
||||
if not root.begins_with("res://"):
|
||||
root = "res://" + root
|
||||
|
||||
var dir := DirAccess.open(root)
|
||||
if dir == null:
|
||||
return {&"ok": false, &"error": "Cannot open directory: " + root}
|
||||
|
||||
var files: PackedStringArray = []
|
||||
var folders: PackedStringArray = []
|
||||
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while name != "":
|
||||
# Skip hidden files unless requested
|
||||
if not include_hidden and name.begins_with("."):
|
||||
name = dir.get_next()
|
||||
continue
|
||||
|
||||
# Skip .uid files
|
||||
if name.ends_with(".uid"):
|
||||
name = dir.get_next()
|
||||
continue
|
||||
|
||||
if dir.current_is_dir():
|
||||
folders.append(name)
|
||||
else:
|
||||
files.append(name)
|
||||
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
# Sort alphabetically
|
||||
files.sort()
|
||||
folders.sort()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"path": root,
|
||||
&"files": files,
|
||||
&"folders": folders,
|
||||
&"total": files.size() + folders.size()
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# read_file - Read contents of a file
|
||||
# =============================================================================
|
||||
func read_file(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
var start_line: int = int(args.get(&"start_line", 1))
|
||||
var end_line: int = int(args.get(&"end_line", 0))
|
||||
var max_bytes: int = int(args.get(&"max_bytes", DEFAULT_MAX_BYTES))
|
||||
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path' parameter"}
|
||||
|
||||
if not path.begins_with("res://"):
|
||||
path = "res://" + path
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File not found: " + path}
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return {&"ok": false, &"error": "Cannot open file: " + path}
|
||||
|
||||
var content: String
|
||||
var line_count: int = 0
|
||||
|
||||
# If no line range specified, read up to max_bytes
|
||||
if end_line <= 0 and start_line <= 1:
|
||||
var size := mini(max_bytes, file.get_length())
|
||||
content = file.get_buffer(size).get_string_from_utf8()
|
||||
# Count lines
|
||||
line_count = content.count("\n") + 1
|
||||
else:
|
||||
# Read specific line range
|
||||
var lines: Array = []
|
||||
var current_line := 0
|
||||
var total_bytes := 0
|
||||
|
||||
while not file.eof_reached():
|
||||
var line := file.get_line()
|
||||
current_line += 1
|
||||
|
||||
if current_line < start_line:
|
||||
continue
|
||||
if end_line > 0 and current_line > end_line:
|
||||
break
|
||||
|
||||
lines.append(line)
|
||||
total_bytes += line.length() + 1 # +1 for newline
|
||||
|
||||
if total_bytes > max_bytes:
|
||||
break
|
||||
|
||||
content = "\n".join(lines)
|
||||
line_count = lines.size()
|
||||
|
||||
file.close()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"path": path,
|
||||
&"content": content,
|
||||
&"line_count": line_count,
|
||||
&"range": [start_line, end_line] if end_line > 0 else null
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# search_project - Search for text in project files
|
||||
# =============================================================================
|
||||
func search_project(args: Dictionary) -> Dictionary:
|
||||
var query: String = str(args.get(&"query", ""))
|
||||
var glob_filter: String = str(args.get(&"glob", ""))
|
||||
var max_results: int = int(args.get(&"max_results", DEFAULT_MAX_RESULTS))
|
||||
var case_sensitive: bool = bool(args.get(&"case_sensitive", false))
|
||||
|
||||
if query.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'query' parameter"}
|
||||
|
||||
var search_query := query if case_sensitive else query.to_lower()
|
||||
var files := _collect_files("res://", glob_filter)
|
||||
var matches: Array = []
|
||||
|
||||
for file_path: String in files:
|
||||
if matches.size() >= max_results:
|
||||
break
|
||||
|
||||
var file := FileAccess.open(file_path, FileAccess.READ)
|
||||
if file == null:
|
||||
continue
|
||||
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var search_content := content if case_sensitive else content.to_lower()
|
||||
if search_content.find(search_query) == -1:
|
||||
continue
|
||||
|
||||
var lines := content.split("\n")
|
||||
for i: int in range(lines.size()):
|
||||
var line := lines[i]
|
||||
var search_line := line if case_sensitive else line.to_lower()
|
||||
if search_line.find(search_query) != -1:
|
||||
matches.append({
|
||||
&"file": file_path,
|
||||
&"line": i + 1,
|
||||
&"content": line.strip_edges()
|
||||
})
|
||||
if matches.size() >= max_results:
|
||||
break
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"query": query,
|
||||
&"matches": matches,
|
||||
&"total_matches": matches.size(),
|
||||
&"truncated": matches.size() >= max_results
|
||||
}
|
||||
|
||||
func _collect_files(path: String, glob_filter: String) -> PackedStringArray:
|
||||
"""Recursively collect all searchable files."""
|
||||
var result: PackedStringArray = []
|
||||
_collect_files_recursive(path, glob_filter, result)
|
||||
return result
|
||||
|
||||
func _collect_files_recursive(path: String, glob_filter: String, out: PackedStringArray, depth: int = 0) -> void:
|
||||
if depth >= MAX_TRAVERSAL_DEPTH:
|
||||
return
|
||||
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while name != "":
|
||||
# Skip hidden
|
||||
if name.begins_with("."):
|
||||
name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(name)
|
||||
|
||||
if dir.current_is_dir():
|
||||
_collect_files_recursive(full_path, glob_filter, out, depth + 1)
|
||||
else:
|
||||
var ext := "." + name.get_extension().to_lower()
|
||||
if not _SKIP_EXTENSIONS.has(ext):
|
||||
if glob_filter.is_empty() or _matches_glob(full_path, glob_filter):
|
||||
out.append(full_path)
|
||||
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
func _matches_glob(path: String, pattern: String) -> bool:
|
||||
"""Simple glob matching: *.gd, **/*.tscn, etc."""
|
||||
# Handle **/*.ext pattern
|
||||
if pattern.begins_with("**/"):
|
||||
var ext := pattern.substr(3) # Remove **/
|
||||
return path.ends_with(ext.replace("*", ""))
|
||||
|
||||
# Handle *.ext pattern
|
||||
if pattern.begins_with("*."):
|
||||
return path.ends_with(pattern.substr(1))
|
||||
|
||||
# Simple contains check
|
||||
return path.find(pattern) != -1
|
||||
|
||||
# =============================================================================
|
||||
# create_script - Create a new GDScript file
|
||||
# =============================================================================
|
||||
func create_script(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
var content: String = str(args.get(&"content", ""))
|
||||
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path' parameter"}
|
||||
|
||||
if not path.begins_with("res://"):
|
||||
path = "res://" + path
|
||||
|
||||
# Add .gd extension if missing
|
||||
if not "." in path.get_file():
|
||||
path += ".gd"
|
||||
|
||||
# Check if file already exists
|
||||
if FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File already exists: " + path}
|
||||
|
||||
# Ensure parent directory exists
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var err := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if err != OK:
|
||||
return {&"ok": false, &"error": "Could not create directory: " + dir_path}
|
||||
|
||||
# Write file
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return {&"ok": false, &"error": "Could not create file: " + path}
|
||||
|
||||
file.store_string(content)
|
||||
file.close()
|
||||
|
||||
# Refresh filesystem so Godot sees the new file
|
||||
_refresh_filesystem()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"path": path,
|
||||
&"size_bytes": content.length(),
|
||||
&"message": "Script created successfully"
|
||||
}
|
||||
|
||||
func _refresh_filesystem() -> void:
|
||||
"""Tell Godot to rescan the filesystem."""
|
||||
if _editor_plugin != null:
|
||||
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
|
||||
elif Engine.is_editor_hint():
|
||||
# Fallback if no plugin reference
|
||||
var editor_interface = Engine.get_singleton("EditorInterface")
|
||||
if editor_interface:
|
||||
editor_interface.get_resource_filesystem().scan()
|
||||
@@ -0,0 +1 @@
|
||||
uid://fqjgqcv4hnjn
|
||||
@@ -0,0 +1,744 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name ProjectTools
|
||||
## Project configuration and debug tools for MCP.
|
||||
## Handles: get_project_settings, list_settings, update_project_settings,
|
||||
## get_input_map, configure_input_map, get_collision_layers,
|
||||
## get_node_properties, setup_autoload,
|
||||
## get_console_log, get_errors, clear_console_log,
|
||||
## open_in_godot, scene_tree_dump
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
# Cached reference to the editor Output panel's RichTextLabel.
|
||||
var _editor_log_rtl: RichTextLabel = null
|
||||
|
||||
# Character offset for clear_console_log.
|
||||
var _clear_char_offset: int = 0
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
# =============================================================================
|
||||
# get_project_settings
|
||||
# =============================================================================
|
||||
func get_project_settings(args: Dictionary) -> Dictionary:
|
||||
var include_render: bool = bool(args.get(&"include_render", true))
|
||||
var include_physics: bool = bool(args.get(&"include_physics", true))
|
||||
|
||||
var out: Dictionary = {}
|
||||
out[&"main_scene"] = str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
||||
|
||||
# Window size
|
||||
var width = ProjectSettings.get_setting("display/window/size/viewport_width", null)
|
||||
var height = ProjectSettings.get_setting("display/window/size/viewport_height", null)
|
||||
if width != null: out[&"window_width"] = int(width)
|
||||
if height != null: out[&"window_height"] = int(height)
|
||||
|
||||
# Stretch
|
||||
var stretch_mode = ProjectSettings.get_setting("display/window/stretch/mode", null)
|
||||
var stretch_aspect = ProjectSettings.get_setting("display/window/stretch/aspect", null)
|
||||
if stretch_mode != null: out[&"stretch_mode"] = str(stretch_mode)
|
||||
if stretch_aspect != null: out[&"stretch_aspect"] = str(stretch_aspect)
|
||||
|
||||
if include_physics:
|
||||
var pps = ProjectSettings.get_setting("physics/common/physics_ticks_per_second", null)
|
||||
if pps != null: out[&"physics_ticks_per_second"] = int(pps)
|
||||
|
||||
if include_render:
|
||||
var method = ProjectSettings.get_setting("rendering/renderer/rendering_method", null)
|
||||
if method != null: out[&"rendering_method"] = str(method)
|
||||
var vsync = ProjectSettings.get_setting("display/window/vsync/vsync_mode", null)
|
||||
if vsync != null: out[&"vsync"] = str(vsync)
|
||||
|
||||
return {&"ok": true, &"settings": out}
|
||||
|
||||
# =============================================================================
|
||||
# list_settings
|
||||
# =============================================================================
|
||||
func list_settings(args: Dictionary) -> Dictionary:
|
||||
var category: String = str(args.get(&"category", ""))
|
||||
|
||||
var properties: Array = ProjectSettings.get_property_list()
|
||||
|
||||
if category.strip_edges().is_empty():
|
||||
var categories: Dictionary = {}
|
||||
for prop: Dictionary in properties:
|
||||
var prop_name: String = prop[&"name"]
|
||||
if prop_name.is_empty() or prop_name.begins_with("_"):
|
||||
continue
|
||||
var slash_idx := prop_name.find("/")
|
||||
if slash_idx == -1:
|
||||
continue
|
||||
var cat: String = prop_name.substr(0, slash_idx)
|
||||
categories[cat] = categories.get(cat, 0) + 1
|
||||
return {&"ok": true, &"categories": categories,
|
||||
&"hint": "Pass a category name to list its settings with current values and valid options."}
|
||||
|
||||
var settings: Array = []
|
||||
for prop: Dictionary in properties:
|
||||
var prop_name: String = prop[&"name"]
|
||||
if not prop_name.begins_with(category + "/"):
|
||||
continue
|
||||
if prop_name.begins_with("_"):
|
||||
continue
|
||||
|
||||
var info: Dictionary = {
|
||||
&"path": prop_name,
|
||||
&"type": _type_to_string(prop[&"type"]),
|
||||
&"value": _serialize_value(ProjectSettings.get_setting(prop_name))
|
||||
}
|
||||
|
||||
var hint: int = prop.get(&"hint", 0)
|
||||
var hint_string: String = str(prop.get(&"hint_string", ""))
|
||||
if hint == PROPERTY_HINT_ENUM and not hint_string.is_empty():
|
||||
info[&"enum_values"] = hint_string
|
||||
elif hint == PROPERTY_HINT_RANGE and not hint_string.is_empty():
|
||||
info[&"range"] = hint_string
|
||||
|
||||
settings.append(info)
|
||||
|
||||
return {&"ok": true, &"category": category, &"settings": settings, &"count": settings.size()}
|
||||
|
||||
# =============================================================================
|
||||
# update_project_settings
|
||||
# =============================================================================
|
||||
func update_project_settings(args: Dictionary) -> Dictionary:
|
||||
var settings = args.get(&"settings", {})
|
||||
if not settings is Dictionary or settings.is_empty():
|
||||
return {&"ok": false, &"error": "Missing or empty 'settings' dictionary. Use list_settings to discover available setting paths."}
|
||||
|
||||
var updated: Array = []
|
||||
for key: String in settings:
|
||||
ProjectSettings.set_setting(key, settings[key])
|
||||
updated.append(key)
|
||||
|
||||
_save_and_refresh_settings()
|
||||
return {&"ok": true, &"updated": updated, &"count": updated.size()}
|
||||
|
||||
# =============================================================================
|
||||
# get_input_map
|
||||
# =============================================================================
|
||||
func get_input_map(args: Dictionary) -> Dictionary:
|
||||
var include_deadzones: bool = bool(args.get(&"include_deadzones", true))
|
||||
var actions: Array = InputMap.get_actions()
|
||||
actions.sort()
|
||||
|
||||
var result: Dictionary = {}
|
||||
for action: StringName in actions:
|
||||
var events: Array = []
|
||||
for e: InputEvent in InputMap.action_get_events(action):
|
||||
var item := {&"type": e.get_class()}
|
||||
|
||||
if e is InputEventKey:
|
||||
var keycode = e.physical_keycode if e.physical_keycode != 0 else e.keycode
|
||||
item[&"keycode"] = keycode
|
||||
item[&"key_label"] = OS.get_keycode_string(keycode) if keycode != 0 else ""
|
||||
elif e is InputEventMouseButton:
|
||||
item[&"button_index"] = e.button_index
|
||||
elif e is InputEventJoypadButton:
|
||||
item[&"button_index"] = e.button_index
|
||||
elif e is InputEventJoypadMotion:
|
||||
item[&"axis"] = e.axis
|
||||
if include_deadzones:
|
||||
item[&"axis_value"] = e.axis_value
|
||||
|
||||
events.append(item)
|
||||
result[action] = events
|
||||
|
||||
return {&"ok": true, &"actions": result, &"count": result.size()}
|
||||
|
||||
# =============================================================================
|
||||
# configure_input_map
|
||||
# =============================================================================
|
||||
func configure_input_map(args: Dictionary) -> Dictionary:
|
||||
var action: String = str(args.get(&"action", ""))
|
||||
var operation: String = str(args.get(&"operation", ""))
|
||||
|
||||
if action.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'action' name"}
|
||||
if operation.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'operation'. Use: add, remove, set"}
|
||||
|
||||
match operation:
|
||||
"add":
|
||||
return _input_map_add(action, args)
|
||||
"remove":
|
||||
return _input_map_remove(action)
|
||||
"set":
|
||||
return _input_map_set(action, args)
|
||||
_:
|
||||
return {&"ok": false, &"error": "Unknown operation: %s. Use: add, remove, set" % operation}
|
||||
|
||||
func _input_map_add(action: String, args: Dictionary) -> Dictionary:
|
||||
var deadzone: float = float(args.get(&"deadzone", 0.5))
|
||||
var events_data: Array = args.get(&"events", [])
|
||||
|
||||
var created := false
|
||||
if not InputMap.has_action(action):
|
||||
InputMap.add_action(action, deadzone)
|
||||
created = true
|
||||
|
||||
var added_events: Array = []
|
||||
var event_errors: Array = []
|
||||
for event_desc in events_data:
|
||||
if not event_desc is Dictionary:
|
||||
continue
|
||||
var result: Dictionary = _create_input_event(event_desc)
|
||||
if result.has(&"error"):
|
||||
event_errors.append(result[&"error"])
|
||||
continue
|
||||
InputMap.action_add_event(action, result[&"event"])
|
||||
added_events.append(_describe_event(result[&"event"]))
|
||||
|
||||
_persist_action(action)
|
||||
_save_and_refresh_settings()
|
||||
_try_refresh_input_map_ui()
|
||||
|
||||
var msg := "Action '%s' %s" % [action, "created" if created else "updated"]
|
||||
if added_events.size() > 0:
|
||||
msg += " with %d event(s)" % added_events.size()
|
||||
|
||||
var out: Dictionary = {&"ok": true, &"message": msg, &"events_added": added_events}
|
||||
if event_errors.size() > 0:
|
||||
out[&"event_errors"] = event_errors
|
||||
return out
|
||||
|
||||
func _input_map_remove(action: String) -> Dictionary:
|
||||
if not InputMap.has_action(action):
|
||||
return {&"ok": false, &"error": "Action not found: " + action}
|
||||
|
||||
InputMap.erase_action(action)
|
||||
if ProjectSettings.has_setting("input/" + action):
|
||||
ProjectSettings.clear("input/" + action)
|
||||
_save_and_refresh_settings()
|
||||
_try_refresh_input_map_ui()
|
||||
|
||||
return {&"ok": true, &"message": "Removed action: " + action}
|
||||
|
||||
func _input_map_set(action: String, args: Dictionary) -> Dictionary:
|
||||
var deadzone: float = float(args.get(&"deadzone", 0.5))
|
||||
var events_data: Array = args.get(&"events", [])
|
||||
|
||||
if InputMap.has_action(action):
|
||||
InputMap.erase_action(action)
|
||||
|
||||
InputMap.add_action(action, deadzone)
|
||||
|
||||
var added_events: Array = []
|
||||
var event_errors: Array = []
|
||||
for event_desc in events_data:
|
||||
if not event_desc is Dictionary:
|
||||
continue
|
||||
var result: Dictionary = _create_input_event(event_desc)
|
||||
if result.has(&"error"):
|
||||
event_errors.append(result[&"error"])
|
||||
continue
|
||||
InputMap.action_add_event(action, result[&"event"])
|
||||
added_events.append(_describe_event(result[&"event"]))
|
||||
|
||||
_persist_action(action)
|
||||
_save_and_refresh_settings()
|
||||
_try_refresh_input_map_ui()
|
||||
|
||||
var out: Dictionary = {&"ok": true, &"message": "Set action '%s' with %d event(s)" % [action, added_events.size()], &"events": added_events}
|
||||
if event_errors.size() > 0:
|
||||
out[&"event_errors"] = event_errors
|
||||
return out
|
||||
|
||||
func _create_input_event(desc: Dictionary) -> Dictionary:
|
||||
var type: String = str(desc.get(&"type", ""))
|
||||
|
||||
match type:
|
||||
"key":
|
||||
var key_string: String = str(desc.get(&"key", ""))
|
||||
if key_string.is_empty():
|
||||
return {&"error": "Missing 'key' for key event"}
|
||||
var event := InputEventKey.new()
|
||||
var keycode := OS.find_keycode_from_string(key_string)
|
||||
if keycode == 0:
|
||||
return {&"error": "Unknown key: " + key_string}
|
||||
event.physical_keycode = keycode
|
||||
return {&"event": event}
|
||||
|
||||
"mouse_button":
|
||||
var button_index: int = int(desc.get(&"button_index", 0))
|
||||
if button_index <= 0:
|
||||
return {&"error": "Invalid 'button_index' for mouse_button (must be >= 1: 1=left, 2=right, 3=middle)"}
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = button_index
|
||||
return {&"event": event}
|
||||
|
||||
"joypad_button":
|
||||
var button_index: int = int(desc.get(&"button_index", -1))
|
||||
if button_index < 0:
|
||||
return {&"error": "Missing or invalid 'button_index' for joypad_button"}
|
||||
var event := InputEventJoypadButton.new()
|
||||
event.button_index = button_index
|
||||
return {&"event": event}
|
||||
|
||||
"joypad_motion":
|
||||
var axis: int = int(desc.get(&"axis", -1))
|
||||
if axis < 0:
|
||||
return {&"error": "Missing or invalid 'axis' for joypad_motion"}
|
||||
var axis_value: float = float(desc.get(&"axis_value", 0.0))
|
||||
var event := InputEventJoypadMotion.new()
|
||||
event.axis = axis
|
||||
event.axis_value = axis_value
|
||||
return {&"event": event}
|
||||
|
||||
_:
|
||||
return {&"error": "Unknown event type: '%s'. Use: key, mouse_button, joypad_button, joypad_motion" % type}
|
||||
|
||||
func _save_and_refresh_settings() -> void:
|
||||
ProjectSettings.save()
|
||||
ProjectSettings.notify_property_list_changed()
|
||||
|
||||
func _try_refresh_input_map_ui() -> void:
|
||||
if not _editor_plugin:
|
||||
return
|
||||
var base := _editor_plugin.get_editor_interface().get_base_control()
|
||||
var pse := _find_node_by_class(base, "ProjectSettingsEditor")
|
||||
if not pse:
|
||||
return
|
||||
if pse.has_method("_update_action_map_editor"):
|
||||
pse.call("_update_action_map_editor")
|
||||
else:
|
||||
push_warning("[Godot MCP] Input map changed and saved, but the editor UI could not refresh. Reopen Project Settings to see changes.")
|
||||
|
||||
func _persist_action(action: String) -> void:
|
||||
if not InputMap.has_action(action):
|
||||
return
|
||||
var deadzone: float = InputMap.action_get_deadzone(action)
|
||||
var events: Array = InputMap.action_get_events(action)
|
||||
ProjectSettings.set_setting("input/" + action, {
|
||||
"deadzone": deadzone,
|
||||
"events": events
|
||||
})
|
||||
|
||||
func _describe_event(event: InputEvent) -> String:
|
||||
if event is InputEventKey:
|
||||
var keycode: int = event.physical_keycode if event.physical_keycode != 0 else event.keycode
|
||||
var label: String = OS.get_keycode_string(keycode) if keycode != 0 else "Unknown"
|
||||
return "Key: " + label
|
||||
elif event is InputEventMouseButton:
|
||||
return "Mouse Button: " + str(event.button_index)
|
||||
elif event is InputEventJoypadButton:
|
||||
return "Joypad Button: " + str(event.button_index)
|
||||
elif event is InputEventJoypadMotion:
|
||||
return "Joypad Axis: %d (%.1f)" % [event.axis, event.axis_value]
|
||||
return event.get_class()
|
||||
|
||||
# =============================================================================
|
||||
# get_collision_layers
|
||||
# =============================================================================
|
||||
func get_collision_layers(_args: Dictionary) -> Dictionary:
|
||||
var layers_2d: Array = _collect_layers("layer_names/2d_physics")
|
||||
var layers_3d: Array = _collect_layers("layer_names/3d_physics")
|
||||
return {&"ok": true, &"layers_2d": layers_2d, &"layers_3d": layers_3d}
|
||||
|
||||
func _collect_layers(prefix: String) -> Array:
|
||||
var out: Array = []
|
||||
for i: int in range(1, 33):
|
||||
var key := "%s/layer_%d" % [prefix, i]
|
||||
var layer_name := str(ProjectSettings.get_setting(key, ""))
|
||||
if not layer_name.is_empty():
|
||||
out.append({&"index": i, &"name": layer_name})
|
||||
return out
|
||||
|
||||
# =============================================================================
|
||||
# get_node_properties
|
||||
# =============================================================================
|
||||
const _SKIP_PROPS: Dictionary = {
|
||||
"script": true, "owner": true, "scene_file_path": true, "unique_name_in_owner": true,
|
||||
}
|
||||
|
||||
const ENUM_HINTS = {
|
||||
"anchors_preset": "0:Top Left,1:Top Right,2:Bottom Right,3:Bottom Left,4:Center Left,5:Center Top,6:Center Right,7:Center Bottom,8:Center,9:Left Wide,10:Top Wide,11:Right Wide,12:Bottom Wide,13:VCenter Wide,14:HCenter Wide,15:Full Rect",
|
||||
"grow_horizontal": "0:Begin,1:End,2:Both",
|
||||
"grow_vertical": "0:Begin,1:End,2:Both",
|
||||
"horizontal_alignment": "0:Left,1:Center,2:Right,3:Fill",
|
||||
"vertical_alignment": "0:Top,1:Center,2:Bottom,3:Fill"
|
||||
}
|
||||
|
||||
func get_node_properties(args: Dictionary) -> Dictionary:
|
||||
var node_type: String = str(args.get(&"node_type", ""))
|
||||
if node_type.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'node_type'"}
|
||||
if not ClassDB.class_exists(node_type):
|
||||
return {&"ok": false, &"error": "Unknown node type: " + node_type}
|
||||
|
||||
var temp = ClassDB.instantiate(node_type)
|
||||
if not temp:
|
||||
return {&"ok": false, &"error": "Cannot instantiate: " + node_type}
|
||||
|
||||
var properties: Array = []
|
||||
for prop: Dictionary in temp.get_property_list():
|
||||
var prop_name: String = prop[&"name"]
|
||||
if prop_name.begins_with("_"):
|
||||
continue
|
||||
if _SKIP_PROPS.has(prop_name):
|
||||
continue
|
||||
if not (prop.get(&"usage", 0) & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
|
||||
var info := {
|
||||
&"name": prop_name,
|
||||
&"type": _type_to_string(prop[&"type"]),
|
||||
&"default": _serialize_value(temp.get(prop_name))
|
||||
}
|
||||
|
||||
# Enum hints
|
||||
if prop.has(&"hint") and prop[&"hint"] == PROPERTY_HINT_ENUM and prop.has(&"hint_string"):
|
||||
info[&"enum_values"] = prop[&"hint_string"]
|
||||
if prop_name in ENUM_HINTS:
|
||||
info[&"enum_values"] = ENUM_HINTS[prop_name]
|
||||
|
||||
properties.append(info)
|
||||
|
||||
temp.queue_free()
|
||||
|
||||
# Inheritance chain
|
||||
var chain: Array = []
|
||||
var cls: String = node_type
|
||||
while cls != "":
|
||||
chain.append(cls)
|
||||
cls = ClassDB.get_parent_class(cls)
|
||||
|
||||
return {&"ok": true, &"node_type": node_type, &"inheritance_chain": chain,
|
||||
&"property_count": properties.size(), &"properties": properties}
|
||||
|
||||
func _type_to_string(type_id: int) -> String:
|
||||
match type_id:
|
||||
TYPE_BOOL: return "bool"
|
||||
TYPE_INT: return "int"
|
||||
TYPE_FLOAT: return "float"
|
||||
TYPE_STRING: return "String"
|
||||
TYPE_VECTOR2: return "Vector2"
|
||||
TYPE_VECTOR3: return "Vector3"
|
||||
TYPE_VECTOR2I: return "Vector2i"
|
||||
TYPE_VECTOR3I: return "Vector3i"
|
||||
TYPE_COLOR: return "Color"
|
||||
TYPE_RECT2: return "Rect2"
|
||||
TYPE_OBJECT: return "Resource"
|
||||
TYPE_ARRAY: return "Array"
|
||||
TYPE_DICTIONARY: return "Dictionary"
|
||||
_: return "Variant"
|
||||
|
||||
func _serialize_value(value: Variant) -> Variant:
|
||||
match typeof(value):
|
||||
TYPE_VECTOR2: return {&"type": &"Vector2", &"x": value.x, &"y": value.y}
|
||||
TYPE_VECTOR3: return {&"type": &"Vector3", &"x": value.x, &"y": value.y, &"z": value.z}
|
||||
TYPE_COLOR: return {&"type": &"Color", &"r": value.r, &"g": value.g, &"b": value.b, &"a": value.a}
|
||||
TYPE_VECTOR2I: return {&"type": &"Vector2i", &"x": value.x, &"y": value.y}
|
||||
TYPE_VECTOR3I: return {&"type": &"Vector3i", &"x": value.x, &"y": value.y, &"z": value.z}
|
||||
TYPE_OBJECT:
|
||||
if value and value is Resource and value.resource_path:
|
||||
return {&"type": &"Resource", &"path": value.resource_path}
|
||||
return null
|
||||
_: return value
|
||||
|
||||
# =============================================================================
|
||||
# setup_autoload
|
||||
# =============================================================================
|
||||
func setup_autoload(args: Dictionary) -> Dictionary:
|
||||
var operation: String = str(args.get(&"operation", ""))
|
||||
|
||||
if operation.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'operation'. Use: add, remove, list"}
|
||||
|
||||
match operation:
|
||||
"list":
|
||||
return _autoload_list()
|
||||
"add":
|
||||
return _autoload_add(args)
|
||||
"remove":
|
||||
return _autoload_remove(args)
|
||||
_:
|
||||
return {&"ok": false, &"error": "Unknown operation: %s. Use: add, remove, list" % operation}
|
||||
|
||||
func _autoload_list() -> Dictionary:
|
||||
var autoloads: Array = []
|
||||
for prop: Dictionary in ProjectSettings.get_property_list():
|
||||
var prop_name: String = prop[&"name"]
|
||||
if not prop_name.begins_with("autoload/"):
|
||||
continue
|
||||
var al_name: String = prop_name.substr(9)
|
||||
var al_path: String = str(ProjectSettings.get_setting(prop_name, ""))
|
||||
var enabled: bool = al_path.begins_with("*")
|
||||
if enabled:
|
||||
al_path = al_path.substr(1)
|
||||
autoloads.append({&"name": al_name, &"path": al_path, &"enabled": enabled})
|
||||
return {&"ok": true, &"autoloads": autoloads, &"count": autoloads.size()}
|
||||
|
||||
func _autoload_add(args: Dictionary) -> Dictionary:
|
||||
var autoload_name: String = str(args.get(&"name", ""))
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
|
||||
if autoload_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'name'"}
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path' for add operation"}
|
||||
|
||||
if not path.begins_with("res://"):
|
||||
path = "res://" + path
|
||||
if not FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File not found: " + path}
|
||||
|
||||
var setting_key := "autoload/" + autoload_name
|
||||
ProjectSettings.set_setting(setting_key, "*" + path)
|
||||
_save_and_refresh_settings()
|
||||
|
||||
return {&"ok": true, &"message": "Registered autoload: %s -> %s" % [autoload_name, path]}
|
||||
|
||||
func _autoload_remove(args: Dictionary) -> Dictionary:
|
||||
var autoload_name: String = str(args.get(&"name", ""))
|
||||
|
||||
if autoload_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'name'"}
|
||||
|
||||
var setting_key := "autoload/" + autoload_name
|
||||
if not ProjectSettings.has_setting(setting_key):
|
||||
return {&"ok": false, &"error": "Autoload not found: " + autoload_name}
|
||||
|
||||
ProjectSettings.clear(setting_key)
|
||||
_save_and_refresh_settings()
|
||||
|
||||
return {&"ok": true, &"message": "Unregistered autoload: " + autoload_name}
|
||||
|
||||
# =============================================================================
|
||||
# Editor Output Panel access
|
||||
# =============================================================================
|
||||
# We read directly from the editor's internal EditorLog RichTextLabel.
|
||||
# This is real-time and matches exactly what the user sees in the Output panel.
|
||||
# =============================================================================
|
||||
|
||||
func _get_editor_log_rtl() -> RichTextLabel:
|
||||
"""Find (and cache) the RichTextLabel inside the editor's Output panel."""
|
||||
if is_instance_valid(_editor_log_rtl):
|
||||
return _editor_log_rtl
|
||||
if not _editor_plugin:
|
||||
return null
|
||||
var base := _editor_plugin.get_editor_interface().get_base_control()
|
||||
var editor_log := _find_node_by_class(base, "EditorLog")
|
||||
if editor_log:
|
||||
_editor_log_rtl = _find_child_rtl(editor_log)
|
||||
return _editor_log_rtl
|
||||
|
||||
func _find_node_by_class(root: Node, cls_name: String) -> Node:
|
||||
if root.get_class() == cls_name:
|
||||
return root
|
||||
for child: Node in root.get_children():
|
||||
var found := _find_node_by_class(child, cls_name)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
func _find_child_rtl(node: Node) -> RichTextLabel:
|
||||
for child: Node in node.get_children():
|
||||
if child is RichTextLabel:
|
||||
return child
|
||||
var found := _find_child_rtl(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
func _read_output_panel_lines() -> Array:
|
||||
"""Return all non-empty lines from the editor Output panel (after clear offset)."""
|
||||
var rtl := _get_editor_log_rtl()
|
||||
if not rtl:
|
||||
return []
|
||||
var full_text: String = rtl.get_parsed_text()
|
||||
if _clear_char_offset > 0 and _clear_char_offset < full_text.length():
|
||||
full_text = full_text.substr(_clear_char_offset)
|
||||
elif _clear_char_offset >= full_text.length():
|
||||
return []
|
||||
var lines: Array = []
|
||||
for line: String in full_text.split("\n"):
|
||||
if not line.strip_edges().is_empty():
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
# =============================================================================
|
||||
# get_console_log
|
||||
# =============================================================================
|
||||
func get_console_log(args: Dictionary) -> Dictionary:
|
||||
var max_lines: int = int(args.get(&"max_lines", 50))
|
||||
|
||||
var rtl := _get_editor_log_rtl()
|
||||
if not rtl:
|
||||
return {&"ok": false,
|
||||
&"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."}
|
||||
|
||||
var all_lines := _read_output_panel_lines()
|
||||
var start := maxi(0, all_lines.size() - max_lines)
|
||||
var lines := all_lines.slice(start)
|
||||
return {&"ok": true, &"lines": lines, &"line_count": lines.size(),
|
||||
&"content": "\n".join(lines)}
|
||||
|
||||
# =============================================================================
|
||||
# get_errors
|
||||
# =============================================================================
|
||||
const _ERROR_PREFIXES: PackedStringArray = [
|
||||
"ERROR:", "SCRIPT ERROR:", "USER ERROR:",
|
||||
"WARNING:", "USER WARNING:", "SCRIPT WARNING:",
|
||||
"Parse Error:", "Invalid",
|
||||
]
|
||||
|
||||
func get_errors(args: Dictionary) -> Dictionary:
|
||||
var max_errors: int = int(args.get(&"max_errors", 50))
|
||||
var include_warnings: bool = bool(args.get(&"include_warnings", true))
|
||||
|
||||
var rtl := _get_editor_log_rtl()
|
||||
if not rtl:
|
||||
return {&"ok": false,
|
||||
&"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."}
|
||||
|
||||
var all_lines := _read_output_panel_lines()
|
||||
|
||||
var all_errors: Array = []
|
||||
for i: int in range(all_lines.size()):
|
||||
var line: String = all_lines[i].strip_edges()
|
||||
if line.is_empty():
|
||||
continue
|
||||
|
||||
var is_error := false
|
||||
var severity := "error"
|
||||
for prefix: String in _ERROR_PREFIXES:
|
||||
if line.begins_with(prefix):
|
||||
is_error = true
|
||||
if "WARNING" in prefix:
|
||||
severity = "warning"
|
||||
break
|
||||
|
||||
# Godot continuation lines: "at: res://path/file.gd:123"
|
||||
if not is_error and line.begins_with("at: ") and "res://" in line:
|
||||
if all_errors.size() > 0:
|
||||
var prev: Dictionary = all_errors[all_errors.size() - 1]
|
||||
var loc := _extract_file_line(line)
|
||||
if not loc.is_empty():
|
||||
prev[&"file"] = loc.get(&"file", "")
|
||||
prev[&"line"] = loc.get(&"line", 0)
|
||||
continue
|
||||
|
||||
if not is_error:
|
||||
continue
|
||||
if severity == "warning" and not include_warnings:
|
||||
continue
|
||||
|
||||
var error_info := {&"message": line, &"severity": severity}
|
||||
var loc := _extract_file_line(line)
|
||||
if not loc.is_empty():
|
||||
error_info[&"file"] = loc.get(&"file", "")
|
||||
error_info[&"line"] = loc.get(&"line", 0)
|
||||
all_errors.append(error_info)
|
||||
|
||||
# Return the most recent errors
|
||||
var start := maxi(0, all_errors.size() - max_errors)
|
||||
var errors := all_errors.slice(start)
|
||||
return {&"ok": true, &"errors": errors, &"error_count": errors.size(),
|
||||
&"summary": "%d error(s) found" % errors.size()}
|
||||
|
||||
func _extract_file_line(text: String) -> Dictionary:
|
||||
var idx := text.find("res://")
|
||||
if idx == -1:
|
||||
return {}
|
||||
var rest := text.substr(idx)
|
||||
var colon_idx := rest.find(":", 6)
|
||||
if colon_idx == -1:
|
||||
return {&"file": rest.strip_edges()}
|
||||
var file_path := rest.substr(0, colon_idx)
|
||||
var after_colon := rest.substr(colon_idx + 1)
|
||||
var line_str := ""
|
||||
for c in after_colon:
|
||||
if c.is_valid_int():
|
||||
line_str += c
|
||||
else:
|
||||
break
|
||||
if not line_str.is_empty():
|
||||
return {&"file": file_path, &"line": int(line_str)}
|
||||
return {&"file": file_path}
|
||||
|
||||
# =============================================================================
|
||||
# clear_console_log
|
||||
# =============================================================================
|
||||
func clear_console_log(_args: Dictionary) -> Dictionary:
|
||||
var rtl := _get_editor_log_rtl()
|
||||
if not rtl:
|
||||
return {&"ok": false,
|
||||
&"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."}
|
||||
|
||||
# Actually clear the editor Output panel
|
||||
rtl.clear()
|
||||
_clear_char_offset = 0
|
||||
return {&"ok": true,
|
||||
&"message": "Console log cleared."}
|
||||
|
||||
# =============================================================================
|
||||
# open_in_godot
|
||||
# =============================================================================
|
||||
func open_in_godot(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
var line: int = int(args.get(&"line", 0))
|
||||
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path'"}
|
||||
|
||||
if not path.begins_with("res://"):
|
||||
path = "res://" + path
|
||||
|
||||
if not _editor_plugin:
|
||||
return {&"ok": false, &"error": "Editor plugin not available"}
|
||||
|
||||
var ei = _editor_plugin.get_editor_interface()
|
||||
|
||||
if path.ends_with(".gd") or path.ends_with(".shader"):
|
||||
var script = load(path)
|
||||
if script:
|
||||
ei.edit_resource(script)
|
||||
if line > 0:
|
||||
ei.get_script_editor().goto_line(line - 1)
|
||||
else:
|
||||
return {&"ok": false, &"error": "Could not load: " + path}
|
||||
elif path.ends_with(".tscn") or path.ends_with(".scn"):
|
||||
ei.open_scene_from_path(path)
|
||||
else:
|
||||
var res = load(path)
|
||||
if res:
|
||||
ei.edit_resource(res)
|
||||
|
||||
return {&"ok": true, &"message": "Opened %s%s" % [path, " at line %d" % line if line > 0 else ""]}
|
||||
|
||||
# =============================================================================
|
||||
# scene_tree_dump
|
||||
# =============================================================================
|
||||
func scene_tree_dump(_args: Dictionary) -> Dictionary:
|
||||
if not _editor_plugin:
|
||||
return {&"ok": false, &"error": "Editor plugin not available"}
|
||||
|
||||
var ei = _editor_plugin.get_editor_interface()
|
||||
var edited_scene = ei.get_edited_scene_root()
|
||||
|
||||
if not edited_scene:
|
||||
return {&"ok": true, &"tree": "(no scene open)", &"message": "No scene is currently open in the editor"}
|
||||
|
||||
var tree_text := _dump_node(edited_scene, 0)
|
||||
|
||||
return {&"ok": true, &"tree": tree_text, &"scene_path": edited_scene.scene_file_path}
|
||||
|
||||
func _dump_node(node: Node, depth: int) -> String:
|
||||
var indent := " ".repeat(depth)
|
||||
var line := "%s%s (%s)" % [indent, node.name, node.get_class()]
|
||||
|
||||
var script = node.get_script()
|
||||
if script:
|
||||
line += " [%s]" % script.resource_path.get_file()
|
||||
|
||||
var children := node.get_children()
|
||||
if children.is_empty():
|
||||
return line
|
||||
|
||||
var parts: PackedStringArray = [line]
|
||||
for child: Node in children:
|
||||
parts.append(_dump_node(child, depth + 1))
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dig5fifwwanvf
|
||||
@@ -0,0 +1,979 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name SceneTools
|
||||
## Scene operation tools for MCP.
|
||||
## Handles: create_scene, read_scene, add_node, remove_node, modify_node_property,
|
||||
## rename_node, move_node, attach_script, detach_script, set_collision_shape,
|
||||
## set_sprite_texture
|
||||
|
||||
const _SKIP_PROPS: Dictionary[String, bool] = {
|
||||
"script": true, "owner": true, "scene_file_path": true,
|
||||
"unique_name_in_owner": true, "editor_description": true,
|
||||
}
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
# =============================================================================
|
||||
# Shared helpers
|
||||
# =============================================================================
|
||||
func _refresh_and_reload(scene_path: String) -> void:
|
||||
_refresh_filesystem()
|
||||
_reload_scene_in_editor(scene_path)
|
||||
|
||||
func _refresh_filesystem() -> void:
|
||||
if _editor_plugin:
|
||||
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
|
||||
|
||||
func _reload_scene_in_editor(scene_path: String) -> void:
|
||||
if not _editor_plugin:
|
||||
return
|
||||
var ei = _editor_plugin.get_editor_interface()
|
||||
var edited = ei.get_edited_scene_root()
|
||||
if edited and edited.scene_file_path == scene_path:
|
||||
ei.reload_scene_from_path(scene_path)
|
||||
|
||||
func _ensure_res_path(path: String) -> String:
|
||||
if not path.begins_with("res://"):
|
||||
return "res://" + path
|
||||
return path
|
||||
|
||||
func _load_scene(scene_path: String) -> Array:
|
||||
"""Returns [scene_root, error_dict]. If error_dict is not empty, scene_root is null."""
|
||||
if not FileAccess.file_exists(scene_path):
|
||||
return [null, {&"ok": false, &"error": "Scene does not exist: " + scene_path}]
|
||||
|
||||
var packed = load(scene_path) as PackedScene
|
||||
if not packed:
|
||||
return [null, {&"ok": false, &"error": "Failed to load scene: " + scene_path}]
|
||||
|
||||
var root = packed.instantiate()
|
||||
if not root:
|
||||
return [null, {&"ok": false, &"error": "Failed to instantiate scene"}]
|
||||
|
||||
return [root, {}]
|
||||
|
||||
func _save_scene(scene_root: Node, scene_path: String) -> Dictionary:
|
||||
"""Pack and save a scene. Returns error dict or empty on success."""
|
||||
var packed = PackedScene.new()
|
||||
var pack_result = packed.pack(scene_root)
|
||||
if pack_result != OK:
|
||||
scene_root.queue_free()
|
||||
return {&"ok": false, &"error": "Failed to pack scene: " + str(pack_result)}
|
||||
|
||||
var save_result = ResourceSaver.save(packed, scene_path)
|
||||
scene_root.queue_free()
|
||||
|
||||
if save_result != OK:
|
||||
return {&"ok": false, &"error": "Failed to save scene: " + str(save_result)}
|
||||
|
||||
_refresh_and_reload(scene_path)
|
||||
return {}
|
||||
|
||||
func _find_node(scene_root: Node, node_path: String) -> Node:
|
||||
if node_path == "." or node_path.is_empty():
|
||||
return scene_root
|
||||
return scene_root.get_node_or_null(node_path)
|
||||
|
||||
func _parse_value(value: Variant) -> Variant:
|
||||
"""Convert dictionary-encoded types to Godot types."""
|
||||
if value is Dictionary:
|
||||
var t: String = value.get(&"type", "")
|
||||
match t:
|
||||
"Vector2": return Vector2(value.get(&"x", 0), value.get(&"y", 0))
|
||||
"Vector3": return Vector3(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
|
||||
"Color": return Color(value.get(&"r", 1), value.get(&"g", 1), value.get(&"b", 1), value.get(&"a", 1))
|
||||
"Vector2i": return Vector2i(value.get(&"x", 0), value.get(&"y", 0))
|
||||
"Vector3i": return Vector3i(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
|
||||
"Rect2": return Rect2(value.get(&"x", 0), value.get(&"y", 0), value.get(&"width", 0), value.get(&"height", 0))
|
||||
return value
|
||||
|
||||
func _set_node_properties(node: Node, properties: Dictionary) -> void:
|
||||
for prop_name: String in properties:
|
||||
var prop_value = _parse_value(properties[prop_name])
|
||||
node.set(prop_name, prop_value)
|
||||
|
||||
func _serialize_value(value: Variant) -> Variant:
|
||||
match typeof(value):
|
||||
TYPE_VECTOR2: return {&"type": &"Vector2", &"x": value.x, &"y": value.y}
|
||||
TYPE_VECTOR3: return {&"type": &"Vector3", &"x": value.x, &"y": value.y, &"z": value.z}
|
||||
TYPE_COLOR: return {&"type": &"Color", &"r": value.r, &"g": value.g, &"b": value.b, &"a": value.a}
|
||||
TYPE_VECTOR2I: return {&"type": &"Vector2i", &"x": value.x, &"y": value.y}
|
||||
TYPE_VECTOR3I: return {&"type": &"Vector3i", &"x": value.x, &"y": value.y, &"z": value.z}
|
||||
TYPE_RECT2: return {&"type": &"Rect2", &"x": value.position.x, &"y": value.position.y, &"width": value.size.x, &"height": value.size.y}
|
||||
TYPE_OBJECT:
|
||||
if value and value is Resource and value.resource_path:
|
||||
return {&"type": &"Resource", &"path": value.resource_path}
|
||||
return null
|
||||
_: return value
|
||||
|
||||
# =============================================================================
|
||||
# create_scene
|
||||
# =============================================================================
|
||||
func create_scene(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var root_node_name: String = str(args.get(&"root_node_name", "Node"))
|
||||
var root_node_type: String = str(args.get(&"root_node_type", ""))
|
||||
var nodes: Array = args.get(&"nodes", [])
|
||||
var attach_script_path: String = str(args.get(&"attach_script", ""))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path' parameter"}
|
||||
if root_node_type.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'root_node_type' parameter"}
|
||||
if not scene_path.ends_with(".tscn"):
|
||||
scene_path += ".tscn"
|
||||
if FileAccess.file_exists(scene_path):
|
||||
return {&"ok": false, &"error": "Scene already exists: " + scene_path}
|
||||
if not ClassDB.class_exists(root_node_type):
|
||||
return {&"ok": false, &"error": "Invalid root node type: " + root_node_type}
|
||||
|
||||
# Ensure parent directory
|
||||
var dir_path := scene_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
|
||||
var root: Node = ClassDB.instantiate(root_node_type) as Node
|
||||
if not root:
|
||||
return {&"ok": false, &"error": "Failed to create root node of type: " + root_node_type}
|
||||
root.name = root_node_name
|
||||
|
||||
if not attach_script_path.is_empty():
|
||||
var script_res = load(attach_script_path)
|
||||
if script_res:
|
||||
root.set_script(script_res)
|
||||
|
||||
var node_count := 0
|
||||
for node_data: Variant in nodes:
|
||||
if typeof(node_data) == TYPE_DICTIONARY:
|
||||
var created = _create_node_recursive(node_data, root, root)
|
||||
if created:
|
||||
node_count += _count_nodes(created)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"path": scene_path, &"root_type": root_node_type, &"child_count": node_count,
|
||||
&"message": "Scene created at " + scene_path}
|
||||
|
||||
func _create_node_recursive(data: Dictionary, parent: Node, owner: Node) -> Node:
|
||||
var n_name: String = str(data.get(&"name", "Node"))
|
||||
var n_type: String = str(data.get(&"type", "Node"))
|
||||
var n_script: String = str(data.get(&"script", ""))
|
||||
var props: Dictionary = data.get(&"properties", {})
|
||||
var children: Array = data.get(&"children", [])
|
||||
|
||||
if not ClassDB.class_exists(n_type):
|
||||
return null
|
||||
var node: Node = ClassDB.instantiate(n_type) as Node
|
||||
if not node:
|
||||
return null
|
||||
|
||||
node.name = n_name
|
||||
_set_node_properties(node, props)
|
||||
|
||||
if not n_script.is_empty():
|
||||
var s = load(n_script)
|
||||
if s:
|
||||
node.set_script(s)
|
||||
|
||||
parent.add_child(node)
|
||||
node.owner = owner
|
||||
|
||||
for child_data: Variant in children:
|
||||
if typeof(child_data) == TYPE_DICTIONARY:
|
||||
_create_node_recursive(child_data, node, owner)
|
||||
return node
|
||||
|
||||
func _count_nodes(node: Node) -> int:
|
||||
var count := 1
|
||||
for child: Node in node.get_children():
|
||||
count += _count_nodes(child)
|
||||
return count
|
||||
|
||||
# =============================================================================
|
||||
# read_scene
|
||||
# =============================================================================
|
||||
func read_scene(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var include_properties: bool = args.get(&"include_properties", false)
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path' parameter"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var structure = _build_node_structure(root, include_properties)
|
||||
root.queue_free()
|
||||
|
||||
return {&"ok": true, &"scene_path": scene_path, &"root": structure}
|
||||
|
||||
func _build_node_structure(node: Node, include_props: bool, path: String = ".") -> Dictionary:
|
||||
const PROPERTIES: PackedStringArray = ["position", "rotation", "scale", "size", "offset", "visible",
|
||||
"modulate", "z_index", "text", "collision_layer", "collision_mask", "mass"]
|
||||
var data := {&"name": str(node.name), &"type": node.get_class(), &"path": path, &"children": []}
|
||||
var script = node.get_script()
|
||||
if script:
|
||||
data[&"script"] = script.resource_path
|
||||
|
||||
if include_props:
|
||||
var props := {}
|
||||
for prop_name: String in PROPERTIES:
|
||||
var val = node.get(prop_name)
|
||||
if val != null:
|
||||
props[prop_name] = _serialize_value(val)
|
||||
if not props.is_empty():
|
||||
data[&"properties"] = props
|
||||
|
||||
for child: Node in node.get_children():
|
||||
var child_path = child.name if path == "." else path + "/" + child.name
|
||||
data[&"children"].append(_build_node_structure(child, include_props, child_path))
|
||||
return data
|
||||
|
||||
# =============================================================================
|
||||
# add_node
|
||||
# =============================================================================
|
||||
func add_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_name: String = str(args.get(&"node_name", ""))
|
||||
var node_type: String = str(args.get(&"node_type", "Node"))
|
||||
var parent_path: String = str(args.get(&"parent_path", "."))
|
||||
var properties: Dictionary = args.get(&"properties", {})
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'node_name'"}
|
||||
if not ClassDB.class_exists(node_type):
|
||||
return {&"ok": false, &"error": "Invalid node type: " + node_type}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var parent = _find_node(root, parent_path)
|
||||
if not parent:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Parent node not found: " + parent_path}
|
||||
|
||||
var new_node: Node = ClassDB.instantiate(node_type) as Node
|
||||
if not new_node:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Failed to create node of type: " + node_type}
|
||||
|
||||
new_node.name = node_name
|
||||
_set_node_properties(new_node, properties)
|
||||
parent.add_child(new_node)
|
||||
new_node.owner = root
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"scene_path": scene_path, &"node_name": node_name, &"node_type": node_type,
|
||||
&"message": "Added %s (%s) to scene" % [node_name, node_type]}
|
||||
|
||||
# =============================================================================
|
||||
# remove_node
|
||||
# =============================================================================
|
||||
func remove_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", ""))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_path.strip_edges().is_empty() or node_path == ".":
|
||||
return {&"ok": false, &"error": "Cannot remove root node"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = root.get_node_or_null(node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var n_name = target.name
|
||||
var n_type = target.get_class()
|
||||
target.get_parent().remove_child(target)
|
||||
target.queue_free()
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"scene_path": scene_path, &"removed_node": node_path,
|
||||
&"message": "Removed %s (%s)" % [n_name, n_type]}
|
||||
|
||||
# =============================================================================
|
||||
# modify_node_property
|
||||
# =============================================================================
|
||||
func modify_node_property(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
var property_name: String = str(args.get(&"property_name", ""))
|
||||
var value = args.get(&"value")
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if property_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'property_name'"}
|
||||
if value == null:
|
||||
return {&"ok": false, &"error": "Missing 'value'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
# Check property exists
|
||||
var prop_exists := false
|
||||
for prop: Dictionary in target.get_property_list():
|
||||
if prop[&"name"] == property_name:
|
||||
prop_exists = true
|
||||
break
|
||||
if not prop_exists:
|
||||
var node_type = target.get_class()
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Property '%s' not found on %s (%s). Use get_node_properties to discover available properties." % [property_name, node_path, node_type]}
|
||||
|
||||
var parsed = _parse_value(value)
|
||||
var old_value = target.get(property_name)
|
||||
|
||||
# Validate resource type compatibility
|
||||
if old_value is Resource and not (parsed is Resource):
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Property '%s' expects a Resource. Use specialized tools (set_collision_shape, set_sprite_texture) instead." % property_name}
|
||||
|
||||
target.set(property_name, parsed)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"scene_path": scene_path, &"node_path": node_path,
|
||||
&"property_name": property_name, &"old_value": str(old_value), &"new_value": str(parsed),
|
||||
&"message": "Set %s.%s = %s" % [node_path, property_name, str(parsed)]}
|
||||
|
||||
# =============================================================================
|
||||
# rename_node
|
||||
# =============================================================================
|
||||
func rename_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", ""))
|
||||
var new_name: String = str(args.get(&"new_name", ""))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'node_path'"}
|
||||
if new_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'new_name'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var old_name = target.name
|
||||
target.name = new_name
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"old_name": str(old_name), &"new_name": new_name,
|
||||
&"message": "Renamed '%s' to '%s'" % [old_name, new_name]}
|
||||
|
||||
# =============================================================================
|
||||
# move_node
|
||||
# =============================================================================
|
||||
func move_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", ""))
|
||||
var new_parent_path: String = str(args.get(&"new_parent_path", "."))
|
||||
var sibling_index: int = int(args.get(&"sibling_index", -1))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_path.strip_edges().is_empty() or node_path == ".":
|
||||
return {&"ok": false, &"error": "Cannot move root node"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = root.get_node_or_null(node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var new_parent = _find_node(root, new_parent_path)
|
||||
if not new_parent:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "New parent not found: " + new_parent_path}
|
||||
|
||||
target.get_parent().remove_child(target)
|
||||
new_parent.add_child(target)
|
||||
target.owner = root
|
||||
|
||||
if sibling_index >= 0:
|
||||
new_parent.move_child(target, mini(sibling_index, new_parent.get_child_count() - 1))
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"message": "Moved '%s' to '%s'" % [node_path, new_parent_path]}
|
||||
|
||||
# =============================================================================
|
||||
# duplicate_node
|
||||
# =============================================================================
|
||||
func duplicate_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", ""))
|
||||
var new_name: String = str(args.get(&"new_name", ""))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_path.strip_edges().is_empty() or node_path == ".":
|
||||
return {&"ok": false, &"error": "Cannot duplicate root node"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = root.get_node_or_null(node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var parent = target.get_parent()
|
||||
if not parent:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Cannot duplicate - no parent"}
|
||||
|
||||
var duplicate = target.duplicate()
|
||||
|
||||
if new_name.is_empty():
|
||||
var base_name = target.name
|
||||
var counter = 2
|
||||
new_name = base_name + str(counter)
|
||||
while parent.has_node(NodePath(new_name)):
|
||||
counter += 1
|
||||
new_name = base_name + str(counter)
|
||||
|
||||
duplicate.name = new_name
|
||||
parent.add_child(duplicate)
|
||||
|
||||
_set_owner_recursive(duplicate, root)
|
||||
|
||||
var original_index = target.get_index()
|
||||
parent.move_child(duplicate, original_index + 1)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"new_name": new_name,
|
||||
&"message": "Duplicated '%s' as '%s'" % [node_path, new_name]}
|
||||
|
||||
|
||||
func _set_owner_recursive(node: Node, owner: Node) -> void:
|
||||
node.owner = owner
|
||||
for child: Node in node.get_children():
|
||||
_set_owner_recursive(child, owner)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# reorder_node - simpler function just for changing sibling order
|
||||
# =============================================================================
|
||||
func reorder_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", ""))
|
||||
var new_index: int = int(args.get(&"new_index", -1))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_path.strip_edges().is_empty() or node_path == ".":
|
||||
return {&"ok": false, &"error": "Cannot reorder root node"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = root.get_node_or_null(node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var parent = target.get_parent()
|
||||
if not parent:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Cannot reorder - no parent"}
|
||||
|
||||
var old_index = target.get_index()
|
||||
var max_index = parent.get_child_count() - 1
|
||||
new_index = clampi(new_index, 0, max_index)
|
||||
|
||||
if old_index == new_index:
|
||||
root.queue_free()
|
||||
return {&"ok": true, &"message": "No change needed"}
|
||||
|
||||
parent.move_child(target, new_index)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"old_index": old_index, &"new_index": new_index,
|
||||
&"message": "Moved '%s' from index %d to %d" % [node_path, old_index, new_index]}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# attach_script
|
||||
# =============================================================================
|
||||
func attach_script(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
var script_path: String = str(args.get(&"script_path", ""))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if script_path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'script_path'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var script_res = load(script_path)
|
||||
if not script_res:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Failed to load script: " + script_path}
|
||||
|
||||
target.set_script(script_res)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"message": "Attached %s to node '%s'" % [script_path, node_path]}
|
||||
|
||||
# =============================================================================
|
||||
# detach_script
|
||||
# =============================================================================
|
||||
func detach_script(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
target.set_script(null)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"message": "Detached script from node '%s'" % node_path}
|
||||
|
||||
# =============================================================================
|
||||
# set_collision_shape
|
||||
# =============================================================================
|
||||
func set_collision_shape(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
var shape_type: String = str(args.get(&"shape_type", ""))
|
||||
var shape_params: Dictionary = args.get(&"shape_params", {})
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if shape_type.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'shape_type'"}
|
||||
if not ClassDB.class_exists(shape_type):
|
||||
return {&"ok": false, &"error": "Invalid shape type: " + shape_type}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var shape = ClassDB.instantiate(shape_type)
|
||||
if not shape:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Failed to create shape: " + shape_type}
|
||||
|
||||
if shape_params.has(&"radius"):
|
||||
shape.set("radius", float(shape_params[&"radius"]))
|
||||
if shape_params.has(&"height"):
|
||||
shape.set("height", float(shape_params[&"height"]))
|
||||
if shape_params.has(&"size"):
|
||||
var size_data = shape_params[&"size"]
|
||||
if typeof(size_data) == TYPE_DICTIONARY:
|
||||
if size_data.has(&"z"):
|
||||
shape.set("size", Vector3(size_data.get(&"x", 1), size_data.get(&"y", 1), size_data.get(&"z", 1)))
|
||||
else:
|
||||
shape.set("size", Vector2(size_data.get(&"x", 1), size_data.get(&"y", 1)))
|
||||
|
||||
target.set("shape", shape)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"message": "Set %s on node '%s'" % [shape_type, node_path]}
|
||||
|
||||
# =============================================================================
|
||||
# set_sprite_texture
|
||||
# =============================================================================
|
||||
func set_sprite_texture(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
var texture_type: String = str(args.get(&"texture_type", ""))
|
||||
var texture_params: Dictionary = args.get(&"texture_params", {})
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if texture_type.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'texture_type'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var texture: Texture2D = null
|
||||
|
||||
match texture_type:
|
||||
"ImageTexture":
|
||||
var tex_path: String = str(texture_params.get(&"path", ""))
|
||||
if tex_path.is_empty():
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Missing 'path' in texture_params for ImageTexture"}
|
||||
texture = load(tex_path)
|
||||
if not texture:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Failed to load texture: " + tex_path}
|
||||
|
||||
"PlaceholderTexture2D":
|
||||
texture = PlaceholderTexture2D.new()
|
||||
var size_data = texture_params.get(&"size", {&"x": 64, &"y": 64})
|
||||
if typeof(size_data) == TYPE_DICTIONARY:
|
||||
texture.size = Vector2(size_data.get(&"x", 64), size_data.get(&"y", 64))
|
||||
|
||||
"GradientTexture2D":
|
||||
texture = GradientTexture2D.new()
|
||||
texture.width = int(texture_params.get(&"width", 64))
|
||||
texture.height = int(texture_params.get(&"height", 64))
|
||||
|
||||
"NoiseTexture2D":
|
||||
texture = NoiseTexture2D.new()
|
||||
texture.width = int(texture_params.get(&"width", 64))
|
||||
texture.height = int(texture_params.get(&"height", 64))
|
||||
|
||||
_:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Unknown texture type: " + texture_type}
|
||||
|
||||
target.set("texture", texture)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"message": "Set %s texture on node '%s'" % [texture_type, node_path]}
|
||||
|
||||
# =============================================================================
|
||||
# get_scene_hierarchy (for visualizer)
|
||||
# =============================================================================
|
||||
func get_scene_hierarchy(args: Dictionary) -> Dictionary:
|
||||
"""Get the full scene hierarchy with node information for the visualizer."""
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var hierarchy = _build_hierarchy_recursive(root, ".")
|
||||
root.queue_free()
|
||||
|
||||
return {&"ok": true, &"scene_path": scene_path, &"hierarchy": hierarchy}
|
||||
|
||||
func _build_hierarchy_recursive(node: Node, path: String) -> Dictionary:
|
||||
"""Build node hierarchy with all info needed for visualizer."""
|
||||
var data := {
|
||||
&"name": str(node.name),
|
||||
&"type": node.get_class(),
|
||||
&"path": path,
|
||||
&"children": [],
|
||||
&"child_count": node.get_child_count()
|
||||
}
|
||||
|
||||
var script = node.get_script()
|
||||
if script:
|
||||
data[&"script"] = script.resource_path
|
||||
|
||||
var parent = node.get_parent()
|
||||
if parent:
|
||||
data[&"index"] = node.get_index()
|
||||
|
||||
for i: int in range(node.get_child_count()):
|
||||
var child = node.get_child(i)
|
||||
var child_path = child.name if path == "." else path + "/" + child.name
|
||||
data[&"children"].append(_build_hierarchy_recursive(child, child_path))
|
||||
|
||||
return data
|
||||
|
||||
# =============================================================================
|
||||
# get_scene_node_properties (dynamic property fetching)
|
||||
# =============================================================================
|
||||
func get_scene_node_properties(args: Dictionary) -> Dictionary:
|
||||
"""Get all properties of a specific node in a scene with their current values."""
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var node_type = target.get_class()
|
||||
var properties: Array = []
|
||||
var categories: Dictionary = {}
|
||||
|
||||
for prop: Dictionary in target.get_property_list():
|
||||
var prop_name: String = prop[&"name"]
|
||||
|
||||
if prop_name.begins_with("_"):
|
||||
continue
|
||||
if _SKIP_PROPS.has(prop_name):
|
||||
continue
|
||||
|
||||
var usage = prop.get(&"usage", 0)
|
||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
|
||||
var current_value = target.get(prop_name)
|
||||
|
||||
var prop_info := {
|
||||
&"name": prop_name,
|
||||
&"type": prop[&"type"],
|
||||
&"type_name": _type_id_to_name(prop[&"type"]),
|
||||
&"hint": prop.get(&"hint", 0),
|
||||
&"hint_string": prop.get(&"hint_string", ""),
|
||||
&"value": _serialize_value(current_value),
|
||||
&"usage": usage
|
||||
}
|
||||
|
||||
var category = _get_property_category(target, prop_name)
|
||||
prop_info[&"category"] = category
|
||||
|
||||
if not categories.has(category):
|
||||
categories[category] = []
|
||||
categories[category].append(prop_info)
|
||||
properties.append(prop_info)
|
||||
|
||||
var chain: Array = []
|
||||
var cls: String = node_type
|
||||
while cls != "":
|
||||
chain.append(cls)
|
||||
cls = ClassDB.get_parent_class(cls)
|
||||
|
||||
root.queue_free()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"scene_path": scene_path,
|
||||
&"node_path": node_path,
|
||||
&"node_type": node_type,
|
||||
&"node_name": target.name,
|
||||
&"inheritance_chain": chain,
|
||||
&"properties": properties,
|
||||
&"categories": categories,
|
||||
&"property_count": properties.size()
|
||||
}
|
||||
|
||||
func _type_id_to_name(type_id: int) -> String:
|
||||
"""Convert Godot type ID to human-readable name."""
|
||||
match type_id:
|
||||
TYPE_NIL: return "null"
|
||||
TYPE_BOOL: return "bool"
|
||||
TYPE_INT: return "int"
|
||||
TYPE_FLOAT: return "float"
|
||||
TYPE_STRING: return "String"
|
||||
TYPE_VECTOR2: return "Vector2"
|
||||
TYPE_VECTOR2I: return "Vector2i"
|
||||
TYPE_RECT2: return "Rect2"
|
||||
TYPE_RECT2I: return "Rect2i"
|
||||
TYPE_VECTOR3: return "Vector3"
|
||||
TYPE_VECTOR3I: return "Vector3i"
|
||||
TYPE_TRANSFORM2D: return "Transform2D"
|
||||
TYPE_VECTOR4: return "Vector4"
|
||||
TYPE_VECTOR4I: return "Vector4i"
|
||||
TYPE_PLANE: return "Plane"
|
||||
TYPE_QUATERNION: return "Quaternion"
|
||||
TYPE_AABB: return "AABB"
|
||||
TYPE_BASIS: return "Basis"
|
||||
TYPE_TRANSFORM3D: return "Transform3D"
|
||||
TYPE_PROJECTION: return "Projection"
|
||||
TYPE_COLOR: return "Color"
|
||||
TYPE_STRING_NAME: return "StringName"
|
||||
TYPE_NODE_PATH: return "NodePath"
|
||||
TYPE_RID: return "RID"
|
||||
TYPE_OBJECT: return "Object"
|
||||
TYPE_CALLABLE: return "Callable"
|
||||
TYPE_SIGNAL: return "Signal"
|
||||
TYPE_DICTIONARY: return "Dictionary"
|
||||
TYPE_ARRAY: return "Array"
|
||||
TYPE_PACKED_BYTE_ARRAY: return "PackedByteArray"
|
||||
TYPE_PACKED_INT32_ARRAY: return "PackedInt32Array"
|
||||
TYPE_PACKED_INT64_ARRAY: return "PackedInt64Array"
|
||||
TYPE_PACKED_FLOAT32_ARRAY: return "PackedFloat32Array"
|
||||
TYPE_PACKED_FLOAT64_ARRAY: return "PackedFloat64Array"
|
||||
TYPE_PACKED_STRING_ARRAY: return "PackedStringArray"
|
||||
TYPE_PACKED_VECTOR2_ARRAY: return "PackedVector2Array"
|
||||
TYPE_PACKED_VECTOR3_ARRAY: return "PackedVector3Array"
|
||||
TYPE_PACKED_COLOR_ARRAY: return "PackedColorArray"
|
||||
_: return "Variant"
|
||||
|
||||
func _get_property_category(node: Node, prop_name: String) -> String:
|
||||
"""Determine which class in the hierarchy defines this property."""
|
||||
var cls: String = node.get_class()
|
||||
while cls != "":
|
||||
var class_props = ClassDB.class_get_property_list(cls, true)
|
||||
for prop: Dictionary in class_props:
|
||||
if prop[&"name"] == prop_name:
|
||||
return cls
|
||||
cls = ClassDB.get_parent_class(cls)
|
||||
return node.get_class()
|
||||
|
||||
# =============================================================================
|
||||
# set_scene_node_property (for visualizer inline editing)
|
||||
# =============================================================================
|
||||
func set_scene_node_property(args: Dictionary) -> Dictionary:
|
||||
"""Set a property on a node in a scene (supports complex types)."""
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
var property_name: String = str(args.get(&"property_name", ""))
|
||||
var value = args.get(&"value")
|
||||
var value_type: int = int(args.get(&"value_type", -1))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if property_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'property_name'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var parsed_value = _parse_typed_value(value, value_type)
|
||||
var old_value = target.get(property_name)
|
||||
|
||||
target.set(property_name, parsed_value)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"scene_path": scene_path,
|
||||
&"node_path": node_path,
|
||||
&"property_name": property_name,
|
||||
&"old_value": _serialize_value(old_value),
|
||||
&"new_value": _serialize_value(parsed_value),
|
||||
&"message": "Set %s.%s" % [node_path, property_name]
|
||||
}
|
||||
|
||||
func _parse_typed_value(value, type_hint: int):
|
||||
"""Parse a value based on its type hint."""
|
||||
if type_hint == -1:
|
||||
return _parse_value(value)
|
||||
|
||||
if typeof(value) == TYPE_DICTIONARY:
|
||||
if value.has(&"type"):
|
||||
return _parse_value(value)
|
||||
|
||||
match type_hint:
|
||||
TYPE_VECTOR2:
|
||||
return Vector2(value.get(&"x", 0), value.get(&"y", 0))
|
||||
TYPE_VECTOR2I:
|
||||
return Vector2i(value.get(&"x", 0), value.get(&"y", 0))
|
||||
TYPE_VECTOR3:
|
||||
return Vector3(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
|
||||
TYPE_VECTOR3I:
|
||||
return Vector3i(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
|
||||
TYPE_COLOR:
|
||||
return Color(value.get(&"r", 1), value.get(&"g", 1), value.get(&"b", 1), value.get(&"a", 1))
|
||||
TYPE_RECT2:
|
||||
return Rect2(value.get(&"x", 0), value.get(&"y", 0), value.get(&"width", 0), value.get(&"height", 0))
|
||||
|
||||
return value
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbcix6lo0tanv
|
||||
@@ -0,0 +1,333 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name ScriptTools
|
||||
## Script and file management tools for MCP.
|
||||
## Handles: edit_script, validate_script, list_scripts,
|
||||
## create_folder, delete_file, rename_file
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
func _refresh_filesystem() -> void:
|
||||
if _editor_plugin:
|
||||
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
|
||||
|
||||
func _ensure_res_path(path: String) -> String:
|
||||
if not path.begins_with("res://"):
|
||||
return "res://" + path
|
||||
return path
|
||||
|
||||
# =============================================================================
|
||||
# edit_script - Apply a small surgical code edit to a GDScript file
|
||||
# =============================================================================
|
||||
func edit_script(args: Dictionary) -> Dictionary:
|
||||
var edit: Dictionary = args.get(&"edit", {})
|
||||
if edit.is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'edit' payload"}
|
||||
|
||||
var path: String = str(edit.get(&"file", ""))
|
||||
if path.is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'file' in edit"}
|
||||
|
||||
path = _ensure_res_path(path)
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File not found: " + path}
|
||||
|
||||
var spec_type: String = str(edit.get(&"type", "snippet_replace"))
|
||||
if spec_type != "snippet_replace":
|
||||
return {&"ok": false, &"error": "Only 'snippet_replace' type is supported"}
|
||||
|
||||
var old_snippet: String = str(edit.get(&"old_snippet", ""))
|
||||
var new_snippet: String = str(edit.get(&"new_snippet", ""))
|
||||
var context_before: String = str(edit.get(&"context_before", ""))
|
||||
var context_after: String = str(edit.get(&"context_after", ""))
|
||||
|
||||
if old_snippet.is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'old_snippet' in edit"}
|
||||
|
||||
# Read current file content
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if not file:
|
||||
return {&"ok": false, &"error": "Cannot read file: " + path}
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
# Find and replace the snippet
|
||||
var search_text := old_snippet
|
||||
var pos := content.find(search_text)
|
||||
|
||||
# If not found directly, try with context
|
||||
if pos == -1 and not context_before.is_empty():
|
||||
var ctx_pos := content.find(context_before)
|
||||
if ctx_pos != -1:
|
||||
var after_ctx := ctx_pos + context_before.length()
|
||||
var remaining := content.substr(after_ctx)
|
||||
var snippet_pos := remaining.find(old_snippet)
|
||||
if snippet_pos != -1:
|
||||
pos = after_ctx + snippet_pos
|
||||
|
||||
if pos == -1:
|
||||
return {&"ok": false, &"error": "Could not find old_snippet in file. Make sure old_snippet matches the file content exactly."}
|
||||
|
||||
# Check for multiple occurrences
|
||||
var second_pos := content.find(search_text, pos + 1)
|
||||
if second_pos != -1 and context_before.is_empty() and context_after.is_empty():
|
||||
return {&"ok": false, &"error": "old_snippet appears multiple times. Add context_before or context_after for disambiguation."}
|
||||
|
||||
# Apply the replacement
|
||||
var original_content := content
|
||||
var new_content := content.substr(0, pos) + new_snippet + content.substr(pos + old_snippet.length())
|
||||
|
||||
# Write back
|
||||
file = FileAccess.open(path, FileAccess.WRITE)
|
||||
if not file:
|
||||
return {&"ok": false, &"error": "Cannot write file: " + path}
|
||||
file.store_string(new_content)
|
||||
file.close()
|
||||
|
||||
# Count changes
|
||||
var old_lines := old_snippet.split("\n")
|
||||
var new_lines := new_snippet.split("\n")
|
||||
var added := maxi(0, new_lines.size() - old_lines.size())
|
||||
var removed := maxi(0, old_lines.size() - new_lines.size())
|
||||
|
||||
_refresh_filesystem()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"path": path,
|
||||
&"added": added,
|
||||
&"removed": removed,
|
||||
&"auto_applied": true,
|
||||
&"message": "Applied edit to %s (+%d -%d lines)" % [path, added, removed]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# validate_script
|
||||
# =============================================================================
|
||||
func validate_script(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path'"}
|
||||
|
||||
path = _ensure_res_path(path)
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File not found: " + path}
|
||||
|
||||
# Read the source text directly from disk so we validate the *current*
|
||||
# file contents, not a stale resource-cache entry.
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if not file:
|
||||
return {&"ok": false, &"error": "Cannot read file: " + path}
|
||||
var source_code := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
# Create a fresh GDScript instance and assign the source for parsing.
|
||||
var script := GDScript.new()
|
||||
script.source_code = source_code
|
||||
|
||||
# reload() triggers the parser/compiler and returns OK or an error code.
|
||||
var err := script.reload()
|
||||
|
||||
if err != OK:
|
||||
# Try to extract useful details from the Godot output log.
|
||||
var errors := _collect_recent_script_errors(path)
|
||||
return {
|
||||
&"ok": true,
|
||||
&"valid": false,
|
||||
&"path": path,
|
||||
&"error_code": err,
|
||||
&"errors": errors,
|
||||
&"message": "Script has errors." + (" Details: " + "; ".join(errors) if errors.size() > 0 else " Check Godot console for details.")
|
||||
}
|
||||
|
||||
if not script.can_instantiate():
|
||||
return {
|
||||
&"ok": true,
|
||||
&"valid": false,
|
||||
&"path": path,
|
||||
&"message": "Script parsed but cannot be instantiated (may have dependency errors)"
|
||||
}
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"valid": true,
|
||||
&"path": path,
|
||||
&"message": "No syntax errors found"
|
||||
}
|
||||
|
||||
func _collect_recent_script_errors(script_path: String) -> Array:
|
||||
"""Grab recent SCRIPT ERROR / Parse Error lines from the editor Output panel
|
||||
that mention the given script path. Best-effort — returns [] if the panel
|
||||
cannot be accessed."""
|
||||
var errors: Array = []
|
||||
if not _editor_plugin:
|
||||
return errors
|
||||
|
||||
# Find the editor's Output panel RichTextLabel
|
||||
var base := _editor_plugin.get_editor_interface().get_base_control()
|
||||
var editor_log := _find_node_by_class(base, "EditorLog")
|
||||
if not editor_log:
|
||||
return errors
|
||||
var rtl := _find_child_rtl(editor_log)
|
||||
if not rtl:
|
||||
return errors
|
||||
|
||||
var text: String = rtl.get_parsed_text()
|
||||
var short_path := script_path.get_file() # e.g. "player.gd"
|
||||
|
||||
for line: String in text.split("\n"):
|
||||
line = line.strip_edges()
|
||||
if line.is_empty():
|
||||
continue
|
||||
if short_path in line or script_path in line:
|
||||
if line.begins_with("SCRIPT ERROR:") or line.begins_with("Parse Error:") \
|
||||
or line.begins_with("ERROR:") or line.begins_with("at:"):
|
||||
errors.append(line)
|
||||
|
||||
# Keep only the last 10 relevant lines
|
||||
if errors.size() > 10:
|
||||
errors = errors.slice(errors.size() - 10)
|
||||
return errors
|
||||
|
||||
func _find_node_by_class(root: Node, cls_name: String) -> Node:
|
||||
if root.get_class() == cls_name:
|
||||
return root
|
||||
for child: Node in root.get_children():
|
||||
var found := _find_node_by_class(child, cls_name)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
func _find_child_rtl(node: Node) -> RichTextLabel:
|
||||
for child: Node in node.get_children():
|
||||
if child is RichTextLabel:
|
||||
return child
|
||||
var found := _find_child_rtl(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
# =============================================================================
|
||||
# list_scripts
|
||||
# =============================================================================
|
||||
func list_scripts(args: Dictionary) -> Dictionary:
|
||||
var scripts: Array = []
|
||||
_collect_scripts("res://", scripts)
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"scripts": scripts,
|
||||
&"count": scripts.size()
|
||||
}
|
||||
|
||||
func _collect_scripts(path: String, out: Array) -> void:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while name != "":
|
||||
if name.begins_with("."):
|
||||
name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(name)
|
||||
if dir.current_is_dir():
|
||||
_collect_scripts(full_path, out)
|
||||
elif name.ends_with(".gd"):
|
||||
out.append(full_path)
|
||||
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
# =============================================================================
|
||||
# create_folder
|
||||
# =============================================================================
|
||||
func create_folder(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path'"}
|
||||
|
||||
path = _ensure_res_path(path)
|
||||
|
||||
if DirAccess.dir_exists_absolute(path):
|
||||
return {&"ok": true, &"path": path, &"message": "Directory already exists"}
|
||||
|
||||
var err := DirAccess.make_dir_recursive_absolute(path)
|
||||
if err != OK:
|
||||
return {&"ok": false, &"error": "Failed to create directory: " + str(err)}
|
||||
|
||||
_refresh_filesystem()
|
||||
|
||||
return {&"ok": true, &"path": path, &"message": "Directory created"}
|
||||
|
||||
# =============================================================================
|
||||
# delete_file
|
||||
# =============================================================================
|
||||
func delete_file(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
var confirm: bool = bool(args.get(&"confirm", false))
|
||||
var create_backup: bool = bool(args.get(&"create_backup", true))
|
||||
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path'"}
|
||||
if not confirm:
|
||||
return {&"ok": false, &"error": "Must set confirm=true to delete"}
|
||||
|
||||
path = _ensure_res_path(path)
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File not found: " + path}
|
||||
|
||||
# Create backup
|
||||
if create_backup:
|
||||
var backup_path := path + ".bak"
|
||||
DirAccess.copy_absolute(path, backup_path)
|
||||
|
||||
var err := DirAccess.remove_absolute(path)
|
||||
if err != OK:
|
||||
return {&"ok": false, &"error": "Failed to delete file: " + str(err)}
|
||||
|
||||
_refresh_filesystem()
|
||||
|
||||
return {&"ok": true, &"path": path, &"message": "File deleted" + (" (backup created)" if create_backup else "")}
|
||||
|
||||
# =============================================================================
|
||||
# rename_file
|
||||
# =============================================================================
|
||||
func rename_file(args: Dictionary) -> Dictionary:
|
||||
var old_path: String = str(args.get(&"old_path", ""))
|
||||
var new_path: String = str(args.get(&"new_path", ""))
|
||||
|
||||
if old_path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'old_path'"}
|
||||
if new_path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'new_path'"}
|
||||
|
||||
old_path = _ensure_res_path(old_path)
|
||||
new_path = _ensure_res_path(new_path)
|
||||
|
||||
if not FileAccess.file_exists(old_path):
|
||||
return {&"ok": false, &"error": "File not found: " + old_path}
|
||||
if FileAccess.file_exists(new_path):
|
||||
return {&"ok": false, &"error": "Target already exists: " + new_path}
|
||||
|
||||
# Ensure target directory exists
|
||||
var dir_path := new_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
|
||||
var err := DirAccess.rename_absolute(old_path, new_path)
|
||||
if err != OK:
|
||||
return {&"ok": false, &"error": "Failed to rename: " + str(err)}
|
||||
|
||||
_refresh_filesystem()
|
||||
|
||||
return {&"ok": true, &"old_path": old_path, &"new_path": new_path,
|
||||
&"message": "Renamed %s to %s" % [old_path, new_path]}
|
||||
@@ -0,0 +1 @@
|
||||
uid://sqjhipl5ff5y
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://cjktr607bngrs
|
||||
Reference in New Issue
Block a user