b0c83af092
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>
745 lines
26 KiB
GDScript
745 lines
26 KiB
GDScript
@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)
|