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:
2026-07-02 20:55:20 -04:00
parent ce39b237c3
commit b0c83af092
4416 changed files with 57418 additions and 902676 deletions
+135
View File
@@ -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
+292
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://fqjgqcv4hnjn
+744
View File
@@ -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
+979
View File
@@ -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
+333
View File
@@ -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