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>
1024 lines
32 KiB
GDScript
1024 lines
32 KiB
GDScript
@tool
|
|
extends Node
|
|
class_name VisualizerTools
|
|
## Crawls a Godot project and parses all GDScript files to build a project map.
|
|
|
|
var _editor_plugin: EditorPlugin = null
|
|
var _scene_tools_ref: Node = null
|
|
|
|
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
|
_editor_plugin = plugin
|
|
|
|
func set_scene_tools_ref(scene_tools: Node) -> void:
|
|
_scene_tools_ref = scene_tools
|
|
|
|
func map_project(args: Dictionary) -> Dictionary:
|
|
"""Crawl the entire project and build a structural map of all scripts."""
|
|
var root_path: String = str(args.get(&"root", "res://"))
|
|
var include_addons: bool = bool(args.get(&"include_addons", false))
|
|
|
|
if not root_path.begins_with("res://"):
|
|
root_path = "res://" + root_path
|
|
|
|
# Collect all .gd files
|
|
var script_paths: Array = []
|
|
_collect_scripts(root_path, script_paths, include_addons)
|
|
|
|
if script_paths.is_empty():
|
|
return {&"ok": false, &"error": "No GDScript files found in " + root_path}
|
|
|
|
# Parse each script
|
|
var nodes: Array = []
|
|
var class_map: Dictionary = {} # class_name -> path
|
|
|
|
for path: String in script_paths:
|
|
var info: Dictionary = _parse_script(path)
|
|
nodes.append(info)
|
|
if info.get(&"class_name", "") != "":
|
|
class_map[info[&"class_name"]] = path
|
|
|
|
# Build edges
|
|
var edges: Array = []
|
|
for node: Dictionary in nodes:
|
|
var from_path: String = node[&"path"]
|
|
|
|
# extends relationship (resolve class_name to path)
|
|
var extends_class: String = node.get(&"extends", "")
|
|
if extends_class in class_map:
|
|
edges.append({&"from": from_path, &"to": class_map[extends_class], &"type": "extends"})
|
|
|
|
# preload/load references
|
|
for ref: String in node.get(&"preloads", []):
|
|
if ref.ends_with(".gd"):
|
|
edges.append({&"from": from_path, &"to": ref, &"type": "preload"})
|
|
|
|
# signal connections
|
|
for conn: Dictionary in node.get(&"connections", []):
|
|
var target: String = conn.get(&"target", "")
|
|
if target in class_map:
|
|
edges.append({&"from": from_path, &"to": class_map[target], &"type": "signal", &"signal_name": conn.get(&"signal", "")})
|
|
|
|
return {
|
|
&"ok": true,
|
|
&"project_map": {
|
|
&"nodes": nodes,
|
|
&"edges": edges,
|
|
&"total_scripts": nodes.size(),
|
|
&"total_connections": edges.size()
|
|
}
|
|
}
|
|
|
|
func _collect_scripts(path: String, results: Array, include_addons: bool) -> void:
|
|
"""Recursively collect all .gd files."""
|
|
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():
|
|
if name == "addons" and not include_addons:
|
|
name = dir.get_next()
|
|
continue
|
|
_collect_scripts(full_path, results, include_addons)
|
|
elif name.ends_with(".gd"):
|
|
results.append(full_path)
|
|
|
|
name = dir.get_next()
|
|
dir.list_dir_end()
|
|
|
|
func _parse_script(path: String) -> Dictionary:
|
|
"""Parse a GDScript file and extract its structure."""
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
return {&"path": path, &"error": "Cannot open file"}
|
|
|
|
var content: String = file.get_as_text()
|
|
file.close()
|
|
|
|
var lines: PackedStringArray = content.split("\n")
|
|
var line_count: int = lines.size()
|
|
|
|
var description := ""
|
|
var extends_class := ""
|
|
var class_name_str := ""
|
|
var variables: Array = []
|
|
var functions: Array = []
|
|
var signals_list: Array = []
|
|
var preloads: Array = []
|
|
var connections: Array = []
|
|
|
|
# Regex patterns
|
|
var re_desc := RegEx.new()
|
|
re_desc.compile("^##\\s*@desc:\\s*(.+)")
|
|
|
|
var re_extends := RegEx.new()
|
|
re_extends.compile("^extends\\s+(\\w+)")
|
|
|
|
var re_class_name := RegEx.new()
|
|
re_class_name.compile("^class_name\\s+(\\w+)")
|
|
|
|
# Match: @export var name: Type = value OR var name: Type OR var name = value
|
|
# Captures: 1=@export, 2=@onready, 3=name, 4=type, 5=default
|
|
var re_var := RegEx.new()
|
|
re_var.compile("^(@export(?:\\([^)]*\\))?\\s+)?(@onready\\s+)?var\\s+(\\w+)\\s*(?::\\s*(\\w+))?(?:\\s*=\\s*(.+))?")
|
|
|
|
# Match: func name(params) -> ReturnType:
|
|
var re_func := RegEx.new()
|
|
re_func.compile("^func\\s+(\\w+)\\s*\\(([^)]*)\\)\\s*(?:->\\s*(\\w+))?")
|
|
|
|
# Match: signal name(params)
|
|
var re_signal := RegEx.new()
|
|
re_signal.compile("^signal\\s+(\\w+)(?:\\(([^)]*)\\))?")
|
|
|
|
var re_preload := RegEx.new()
|
|
re_preload.compile("(?:preload|load)\\s*\\(\\s*\"(res://[^\"]+)\"\\s*\\)")
|
|
|
|
# Match: obj.signal.connect(...) pattern (Godot 4 style)
|
|
var re_connect_obj := RegEx.new()
|
|
re_connect_obj.compile("(\\w+)\\.(\\w+)\\.connect\\s*\\(")
|
|
|
|
# Match: signal.connect(...) pattern (direct signal)
|
|
var re_connect_direct := RegEx.new()
|
|
re_connect_direct.compile("^\\s*(\\w+)\\.connect\\s*\\(")
|
|
|
|
# Map of variable names to their types (for resolving signal connections)
|
|
var var_type_map: Dictionary = {}
|
|
|
|
# First pass: extract metadata and find function boundaries
|
|
var func_starts: Array = [] # [{line_idx, name}]
|
|
|
|
for i: int in range(line_count):
|
|
var line: String = lines[i]
|
|
var stripped: String = line.strip_edges()
|
|
|
|
# Description tag (check first 15 lines)
|
|
if i < 15 and description.is_empty():
|
|
var m := re_desc.search(stripped)
|
|
if m:
|
|
description = m.get_string(1)
|
|
continue
|
|
|
|
# extends
|
|
if extends_class.is_empty():
|
|
var m := re_extends.search(stripped)
|
|
if m:
|
|
extends_class = m.get_string(1)
|
|
continue
|
|
|
|
# class_name
|
|
if class_name_str.is_empty():
|
|
var m := re_class_name.search(stripped)
|
|
if m:
|
|
class_name_str = m.get_string(1)
|
|
continue
|
|
|
|
# Variables - only match top-level (not indented)
|
|
# Skip lines that start with tab or spaces (inside functions)
|
|
if not line.begins_with("\t") and not line.begins_with(" "):
|
|
var m_var := re_var.search(stripped)
|
|
if m_var:
|
|
var exported: bool = m_var.get_string(1).strip_edges() != ""
|
|
var onready: bool = m_var.get_string(2).strip_edges() != ""
|
|
var var_name: String = m_var.get_string(3)
|
|
var var_type: String = m_var.get_string(4).strip_edges()
|
|
var default_val: String = m_var.get_string(5).strip_edges()
|
|
|
|
# Try to infer type from default value if no explicit type
|
|
if var_type.is_empty() and not default_val.is_empty():
|
|
var_type = _infer_type(default_val)
|
|
|
|
# Track variable types for signal connection resolution
|
|
if not var_type.is_empty():
|
|
var_type_map[var_name] = var_type
|
|
|
|
variables.append({
|
|
&"name": var_name,
|
|
&"type": var_type,
|
|
&"exported": exported,
|
|
&"onready": onready,
|
|
&"default": default_val
|
|
})
|
|
|
|
# Functions
|
|
var m_func := re_func.search(stripped)
|
|
if m_func:
|
|
var func_name: String = m_func.get_string(1)
|
|
var return_type: String = m_func.get_string(3).strip_edges()
|
|
func_starts.append({&"line_idx": i, &"name": func_name})
|
|
functions.append({
|
|
&"name": func_name,
|
|
&"params": m_func.get_string(2).strip_edges(),
|
|
&"return_type": return_type,
|
|
&"line": i + 1,
|
|
&"body": "" # filled in second pass
|
|
})
|
|
|
|
# Signals
|
|
var m_sig := re_signal.search(stripped)
|
|
if m_sig:
|
|
signals_list.append({
|
|
&"name": m_sig.get_string(1),
|
|
&"params": m_sig.get_string(2).strip_edges() if m_sig.get_string(2) else ""
|
|
})
|
|
|
|
# Preload/load references
|
|
var m_preload := re_preload.search(stripped)
|
|
if m_preload:
|
|
preloads.append(m_preload.get_string(1))
|
|
|
|
# Signal connections (Godot 4 style)
|
|
# Pattern: obj.signal.connect(...) - e.g. wave_manager.wave_started.connect(...)
|
|
var m_conn_obj := re_connect_obj.search(stripped)
|
|
if m_conn_obj:
|
|
var obj_name: String = m_conn_obj.get_string(1)
|
|
var signal_name: String = m_conn_obj.get_string(2)
|
|
var target_type: String = var_type_map.get(obj_name, "")
|
|
connections.append({
|
|
&"object": obj_name,
|
|
&"signal": signal_name,
|
|
&"target": target_type,
|
|
&"line": i + 1
|
|
})
|
|
else:
|
|
# Pattern: signal.connect(...) - e.g. body_entered.connect(...)
|
|
var m_conn_direct := re_connect_direct.search(stripped)
|
|
if m_conn_direct:
|
|
connections.append({
|
|
&"signal": m_conn_direct.get_string(1),
|
|
&"target": extends_class, # Direct signal likely from parent class
|
|
&"line": i + 1
|
|
})
|
|
|
|
# Second pass: extract function bodies
|
|
for fi: int in range(func_starts.size()):
|
|
var start_idx: int = func_starts[fi][&"line_idx"]
|
|
var end_idx: int
|
|
if fi + 1 < func_starts.size():
|
|
end_idx = func_starts[fi + 1][&"line_idx"]
|
|
else:
|
|
end_idx = line_count
|
|
|
|
# Find actual end: look backwards from next func to skip blank lines
|
|
while end_idx > start_idx + 1 and lines[end_idx - 1].strip_edges().is_empty():
|
|
end_idx -= 1
|
|
|
|
# Also check for top-level declarations (var, signal, @export, class_name, etc.)
|
|
# that would end the function body
|
|
for check_idx in range(start_idx + 1, end_idx):
|
|
var check_line: String = lines[check_idx]
|
|
# If line is not indented and not empty and not a comment, it's not part of the function
|
|
if not check_line.is_empty() and not check_line.begins_with("\t") and not check_line.begins_with(" ") and not check_line.begins_with("#"):
|
|
end_idx = check_idx
|
|
break
|
|
|
|
var body_lines: PackedStringArray = PackedStringArray()
|
|
for li: int in range(start_idx, end_idx):
|
|
body_lines.append(lines[li])
|
|
|
|
var body: String = "\n".join(body_lines)
|
|
# Cap body size to avoid huge payloads
|
|
if body.length() > 3000:
|
|
body = body.substr(0, 3000) + "\n# ... (truncated)"
|
|
|
|
functions[fi][&"body"] = body
|
|
functions[fi][&"body_lines"] = end_idx - start_idx
|
|
|
|
# Determine folder
|
|
var folder: String = path.get_base_dir()
|
|
var filename: String = path.get_file()
|
|
|
|
return {
|
|
&"path": path,
|
|
&"filename": filename,
|
|
&"folder": folder,
|
|
&"class_name": class_name_str,
|
|
&"extends": extends_class,
|
|
&"description": description,
|
|
&"line_count": line_count,
|
|
&"variables": variables,
|
|
&"functions": functions,
|
|
&"signals": signals_list,
|
|
&"preloads": preloads,
|
|
&"connections": connections
|
|
}
|
|
|
|
func _infer_type(default_val: String) -> String:
|
|
"""Try to infer GDScript type from a default value."""
|
|
if default_val == "true" or default_val == "false":
|
|
return "bool"
|
|
if default_val.is_valid_int():
|
|
return "int"
|
|
if default_val.is_valid_float():
|
|
return "float"
|
|
if default_val.begins_with("\"") or default_val.begins_with("'"):
|
|
return "String"
|
|
if default_val.begins_with("Vector2"):
|
|
return "Vector2"
|
|
if default_val.begins_with("Vector3"):
|
|
return "Vector3"
|
|
if default_val.begins_with("Color"):
|
|
return "Color"
|
|
if default_val.begins_with("["):
|
|
return "Array"
|
|
if default_val.begins_with("{"):
|
|
return "Dictionary"
|
|
if default_val == "null":
|
|
return "Variant"
|
|
if default_val.ends_with(".new()"):
|
|
return default_val.replace(".new()", "")
|
|
return ""
|
|
|
|
|
|
func map_scenes(args: Dictionary) -> Dictionary:
|
|
"""Crawl the project and build a map of all scenes."""
|
|
var root_path: String = str(args.get(&"root", "res://"))
|
|
var include_addons: bool = bool(args.get(&"include_addons", false))
|
|
|
|
if not root_path.begins_with("res://"):
|
|
root_path = "res://" + root_path
|
|
|
|
# Collect all .tscn files
|
|
var scene_paths: Array = []
|
|
_collect_scenes(root_path, scene_paths, include_addons)
|
|
|
|
if scene_paths.is_empty():
|
|
return {&"ok": true, &"scene_map": {&"scenes": [], &"total_scenes": 0}}
|
|
|
|
# Parse each scene
|
|
var scenes: Array = []
|
|
for path: String in scene_paths:
|
|
var info: Dictionary = _parse_scene(path)
|
|
scenes.append(info)
|
|
|
|
# Build edges between scenes (instantiation, preloads)
|
|
var edges: Array = []
|
|
for scene: Dictionary in scenes:
|
|
var from_path: String = scene[&"path"]
|
|
for instance: String in scene.get(&"instances", []):
|
|
edges.append({&"from": from_path, &"to": instance, &"type": "instance"})
|
|
|
|
return {
|
|
&"ok": true,
|
|
&"scene_map": {
|
|
&"scenes": scenes,
|
|
&"edges": edges,
|
|
&"total_scenes": scenes.size()
|
|
}
|
|
}
|
|
|
|
|
|
func _collect_scenes(path: String, results: Array, include_addons: bool) -> void:
|
|
"""Recursively collect all .tscn files."""
|
|
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():
|
|
if name == "addons" and not include_addons:
|
|
name = dir.get_next()
|
|
continue
|
|
_collect_scenes(full_path, results, include_addons)
|
|
elif name.ends_with(".tscn"):
|
|
results.append(full_path)
|
|
|
|
name = dir.get_next()
|
|
dir.list_dir_end()
|
|
|
|
|
|
func _parse_scene(path: String) -> Dictionary:
|
|
"""Parse a scene file and extract its structure."""
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
return {&"path": path, &"error": "Cannot open file"}
|
|
|
|
var content: String = file.get_as_text()
|
|
file.close()
|
|
|
|
var scene_name: String = path.get_file().replace(".tscn", "")
|
|
var root_type: String = ""
|
|
var nodes: Array = []
|
|
var instances: Array = []
|
|
var scripts: Array = []
|
|
|
|
# Parse .tscn format
|
|
var lines: PackedStringArray = content.split("\n")
|
|
var current_node: Dictionary = {}
|
|
|
|
var re_ext_resource := RegEx.new()
|
|
re_ext_resource.compile('\\[ext_resource.*path="([^"]+)".*type="([^"]+)"')
|
|
|
|
var re_node := RegEx.new()
|
|
re_node.compile('\\[node name="([^"]+)".*type="([^"]+)"')
|
|
|
|
var re_node_instance := RegEx.new()
|
|
re_node_instance.compile('\\[node name="([^"]+)".*instance=ExtResource\\("([^"]+)"\\)')
|
|
|
|
var re_script := RegEx.new()
|
|
re_script.compile('script = ExtResource\\("([^"]+)"\\)')
|
|
|
|
var ext_resources: Dictionary = {} # id -> {path, type}
|
|
|
|
for line: String in lines:
|
|
# External resources
|
|
var m_ext := re_ext_resource.search(line)
|
|
if m_ext:
|
|
var res_path: String = m_ext.get_string(1)
|
|
var res_type: String = m_ext.get_string(2)
|
|
# Extract ID from the line
|
|
var id_match := RegEx.create_from_string('id="([^"]+)"').search(line)
|
|
if id_match:
|
|
ext_resources[id_match.get_string(1)] = {&"path": res_path, &"type": res_type}
|
|
if res_type == "PackedScene":
|
|
instances.append(res_path)
|
|
elif res_type == "Script":
|
|
scripts.append(res_path)
|
|
continue
|
|
|
|
# Regular nodes
|
|
var m_node := re_node.search(line)
|
|
if m_node:
|
|
var node_name: String = m_node.get_string(1)
|
|
var node_type: String = m_node.get_string(2)
|
|
if root_type.is_empty():
|
|
root_type = node_type
|
|
nodes.append({&"name": node_name, &"type": node_type})
|
|
continue
|
|
|
|
# Instance nodes
|
|
var m_inst := re_node_instance.search(line)
|
|
if m_inst:
|
|
var node_name: String = m_inst.get_string(1)
|
|
nodes.append({&"name": node_name, &"type": "Instance"})
|
|
|
|
return {
|
|
&"path": path,
|
|
&"name": scene_name,
|
|
&"root_type": root_type,
|
|
&"nodes": nodes,
|
|
&"instances": instances,
|
|
&"scripts": scripts,
|
|
&"node_count": nodes.size()
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# INTERNAL FILE MODIFICATION FUNCTIONS (not exposed as MCP tools)
|
|
# These are called directly by the visualizer for inline editing
|
|
# ============================================================================
|
|
|
|
func _internal_map_scenes(args: Dictionary) -> Dictionary:
|
|
"""Internal wrapper for map_scenes."""
|
|
return map_scenes(args)
|
|
|
|
|
|
func _internal_refresh_map(args: Dictionary) -> Dictionary:
|
|
"""Refresh the project map."""
|
|
return map_project(args)
|
|
|
|
|
|
func _internal_create_script_file(args: Dictionary) -> Dictionary:
|
|
"""Create a new script file."""
|
|
var script_path: String = args.get(&"path", "")
|
|
var extends_type: String = args.get(&"extends", "Node")
|
|
var class_name_str: String = args.get(&"class_name", "")
|
|
|
|
if script_path.is_empty():
|
|
return {&"ok": false, &"error": "No path provided"}
|
|
|
|
if not script_path.begins_with("res://"):
|
|
script_path = "res://" + script_path
|
|
|
|
if not script_path.ends_with(".gd"):
|
|
script_path += ".gd"
|
|
|
|
# Check if file already exists
|
|
if FileAccess.file_exists(script_path):
|
|
return {&"ok": false, &"error": "File already exists: " + script_path}
|
|
|
|
# Create directory if needed
|
|
var dir_path: String = script_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": "Failed to create directory"}
|
|
|
|
# Build script content
|
|
var content := ""
|
|
if not class_name_str.is_empty():
|
|
content += "class_name " + class_name_str + "\n"
|
|
content += "extends " + extends_type + "\n"
|
|
content += "\n\n"
|
|
content += "func _ready() -> void:\n"
|
|
content += "\tpass\n"
|
|
|
|
# Write file
|
|
var file := FileAccess.open(script_path, FileAccess.WRITE)
|
|
if file == null:
|
|
return {&"ok": false, &"error": "Cannot create file: " + script_path}
|
|
|
|
file.store_string(content)
|
|
file.close()
|
|
|
|
return {&"ok": true, &"path": script_path}
|
|
|
|
|
|
func _internal_modify_variable(args: Dictionary) -> Dictionary:
|
|
"""Add, update, or delete a variable in a script file."""
|
|
var script_path: String = args.get(&"path", "")
|
|
var action: String = args.get(&"action", "") # "add", "update", "delete"
|
|
var old_name: String = args.get(&"old_name", "")
|
|
var new_name: String = args.get(&"name", "")
|
|
var var_type: String = args.get(&"type", "")
|
|
var default_val: String = args.get(&"default", "")
|
|
var exported: bool = args.get(&"exported", false)
|
|
var onready: bool = args.get(&"onready", false)
|
|
|
|
if script_path.is_empty():
|
|
return {&"ok": false, &"error": "No script path provided"}
|
|
|
|
var file := FileAccess.open(script_path, FileAccess.READ)
|
|
if file == null:
|
|
return {&"ok": false, &"error": "Cannot open file: " + script_path}
|
|
|
|
var content: String = file.get_as_text()
|
|
file.close()
|
|
|
|
var lines: Array = Array(content.split("\n"))
|
|
var modified := false
|
|
|
|
if action == "delete":
|
|
# Find and remove the variable declaration
|
|
var pattern := RegEx.new()
|
|
pattern.compile("^(@export(?:\\([^)]*\\))?\\s+)?(?:@onready\\s+)?var\\s+" + old_name + "\\s*(?::|=|$)")
|
|
for i: int in range(lines.size() - 1, -1, -1):
|
|
if pattern.search(lines[i].strip_edges()):
|
|
lines.remove_at(i)
|
|
modified = true
|
|
break
|
|
|
|
elif action == "update":
|
|
# Find and update the variable declaration
|
|
var pattern := RegEx.new()
|
|
pattern.compile("^(@export(?:\\([^)]*\\))?\\s+)?(@onready\\s+)?var\\s+" + old_name + "\\s*(?::\\s*\\w+)?(?:\\s*=\\s*.+)?$")
|
|
for i: int in range(lines.size()):
|
|
var m := pattern.search(lines[i].strip_edges())
|
|
if m:
|
|
# Use onready from args, not from matched pattern
|
|
var new_line := _build_var_line(new_name, var_type, default_val, exported, onready)
|
|
lines[i] = new_line
|
|
modified = true
|
|
break
|
|
|
|
elif action == "add":
|
|
# Find position to insert (after last variable, before first function)
|
|
var insert_pos := _find_var_insert_position(lines, exported)
|
|
var new_line := _build_var_line(new_name, var_type, default_val, exported, false)
|
|
lines.insert(insert_pos, new_line)
|
|
modified = true
|
|
|
|
if modified:
|
|
var new_content := "\n".join(PackedStringArray(lines))
|
|
var write_file := FileAccess.open(script_path, FileAccess.WRITE)
|
|
if write_file == null:
|
|
return {&"ok": false, &"error": "Cannot write to file: " + script_path}
|
|
write_file.store_string(new_content)
|
|
write_file.close()
|
|
return {&"ok": true, &"action": action, &"variable": new_name}
|
|
|
|
return {&"ok": false, &"error": "Variable not found: " + old_name}
|
|
|
|
|
|
func _internal_modify_signal(args: Dictionary) -> Dictionary:
|
|
"""Add, update, or delete a signal in a script file."""
|
|
var script_path: String = args.get(&"path", "")
|
|
var action: String = args.get(&"action", "")
|
|
var old_name: String = args.get(&"old_name", "")
|
|
var new_name: String = args.get(&"name", "")
|
|
var params: String = args.get(&"params", "")
|
|
|
|
if script_path.is_empty():
|
|
return {&"ok": false, &"error": "No script path provided"}
|
|
|
|
var file := FileAccess.open(script_path, FileAccess.READ)
|
|
if file == null:
|
|
return {&"ok": false, &"error": "Cannot open file: " + script_path}
|
|
|
|
var content: String = file.get_as_text()
|
|
file.close()
|
|
|
|
var lines: Array = Array(content.split("\n"))
|
|
var modified := false
|
|
|
|
if action == "delete":
|
|
var pattern := RegEx.new()
|
|
pattern.compile("^signal\\s+" + old_name + "(?:\\s*\\(|$)")
|
|
for i: int in range(lines.size() - 1, -1, -1):
|
|
if pattern.search(lines[i].strip_edges()):
|
|
lines.remove_at(i)
|
|
modified = true
|
|
break
|
|
|
|
elif action == "update":
|
|
var pattern := RegEx.new()
|
|
pattern.compile("^signal\\s+" + old_name + "(?:\\s*\\([^)]*\\))?$")
|
|
for i: int in range(lines.size()):
|
|
if pattern.search(lines[i].strip_edges()):
|
|
var new_line := "signal " + new_name
|
|
if not params.is_empty():
|
|
new_line += "(" + params + ")"
|
|
lines[i] = new_line
|
|
modified = true
|
|
break
|
|
|
|
elif action == "add":
|
|
var insert_pos := _find_signal_insert_position(lines)
|
|
var new_line := "signal " + new_name
|
|
if not params.is_empty():
|
|
new_line += "(" + params + ")"
|
|
lines.insert(insert_pos, new_line)
|
|
modified = true
|
|
|
|
if modified:
|
|
var new_content := "\n".join(PackedStringArray(lines))
|
|
var write_file := FileAccess.open(script_path, FileAccess.WRITE)
|
|
if write_file == null:
|
|
return {&"ok": false, &"error": "Cannot write to file: " + script_path}
|
|
write_file.store_string(new_content)
|
|
write_file.close()
|
|
return {&"ok": true, &"action": action, &"signal": new_name}
|
|
|
|
return {&"ok": false, &"error": "Signal not found: " + old_name}
|
|
|
|
|
|
func _internal_modify_function(args: Dictionary) -> Dictionary:
|
|
"""Update a function's body in a script file."""
|
|
var script_path: String = args.get(&"path", "")
|
|
var func_name: String = args.get(&"name", "")
|
|
var new_body: String = args.get(&"body", "")
|
|
|
|
if script_path.is_empty() or func_name.is_empty():
|
|
return {&"ok": false, &"error": "Missing path or function name"}
|
|
|
|
var file := FileAccess.open(script_path, FileAccess.READ)
|
|
if file == null:
|
|
return {&"ok": false, &"error": "Cannot open file: " + script_path}
|
|
|
|
var content: String = file.get_as_text()
|
|
file.close()
|
|
|
|
var lines: Array = Array(content.split("\n"))
|
|
|
|
# Find the function
|
|
var re_func := RegEx.new()
|
|
re_func.compile("^func\\s+" + func_name + "\\s*\\(")
|
|
|
|
var func_start := -1
|
|
var func_end := -1
|
|
|
|
for i: int in range(lines.size()):
|
|
if func_start == -1:
|
|
if re_func.search(lines[i].strip_edges()):
|
|
func_start = i
|
|
elif func_start != -1:
|
|
# Find end of function (next top-level declaration or end of file)
|
|
var stripped: String = lines[i].strip_edges()
|
|
if not stripped.is_empty() and not lines[i].begins_with("\t") and not lines[i].begins_with(" ") and not stripped.begins_with("#"):
|
|
func_end = i
|
|
break
|
|
|
|
if func_start == -1:
|
|
return {&"ok": false, &"error": "Function not found: " + func_name}
|
|
|
|
if func_end == -1:
|
|
func_end = lines.size()
|
|
|
|
# Remove trailing empty lines from function body
|
|
while func_end > func_start + 1 and lines[func_end - 1].strip_edges().is_empty():
|
|
func_end -= 1
|
|
|
|
# Replace function body
|
|
var new_lines := Array(new_body.split("\n"))
|
|
|
|
# Remove old function lines
|
|
for i: int in range(func_end - 1, func_start - 1, -1):
|
|
lines.remove_at(i)
|
|
|
|
# Insert new function lines
|
|
for i: int in range(new_lines.size()):
|
|
lines.insert(func_start + i, new_lines[i])
|
|
|
|
var new_content := "\n".join(PackedStringArray(lines))
|
|
var write_file := FileAccess.open(script_path, FileAccess.WRITE)
|
|
if write_file == null:
|
|
return {&"ok": false, &"error": "Cannot write to file: " + script_path}
|
|
write_file.store_string(new_content)
|
|
write_file.close()
|
|
|
|
return {&"ok": true, &"function": func_name}
|
|
|
|
|
|
func _internal_modify_function_delete(args: Dictionary) -> Dictionary:
|
|
"""Delete a function from a script file."""
|
|
var script_path: String = args.get(&"path", "")
|
|
var func_name: String = args.get(&"name", "")
|
|
|
|
if script_path.is_empty() or func_name.is_empty():
|
|
return {&"ok": false, &"error": "Missing path or function name"}
|
|
|
|
var file := FileAccess.open(script_path, FileAccess.READ)
|
|
if file == null:
|
|
return {&"ok": false, &"error": "Cannot open file: " + script_path}
|
|
|
|
var content: String = file.get_as_text()
|
|
file.close()
|
|
|
|
var lines: Array = Array(content.split("\n"))
|
|
|
|
# Find the function
|
|
var re_func := RegEx.new()
|
|
re_func.compile("^func\\s+" + func_name + "\\s*\\(")
|
|
|
|
var func_start := -1
|
|
var func_end := -1
|
|
|
|
for i: int in range(lines.size()):
|
|
if func_start == -1:
|
|
if re_func.search(lines[i].strip_edges()):
|
|
func_start = i
|
|
elif func_start != -1:
|
|
# Find end of function (next top-level declaration or end of file)
|
|
var stripped: String = lines[i].strip_edges()
|
|
if not stripped.is_empty() and not lines[i].begins_with("\t") and not lines[i].begins_with(" ") and not stripped.begins_with("#"):
|
|
func_end = i
|
|
break
|
|
|
|
if func_start == -1:
|
|
return {&"ok": false, &"error": "Function not found: " + func_name}
|
|
|
|
if func_end == -1:
|
|
func_end = lines.size()
|
|
|
|
# Remove trailing empty lines before the function
|
|
while func_end > func_start + 1 and lines[func_end - 1].strip_edges().is_empty():
|
|
func_end -= 1
|
|
|
|
# Remove the function lines
|
|
for i: int in range(func_end - 1, func_start - 1, -1):
|
|
lines.remove_at(i)
|
|
|
|
# Remove extra blank lines that might be left
|
|
# (but keep at least one blank line between declarations)
|
|
|
|
var new_content := "\n".join(PackedStringArray(lines))
|
|
var write_file := FileAccess.open(script_path, FileAccess.WRITE)
|
|
if write_file == null:
|
|
return {&"ok": false, &"error": "Cannot write to file: " + script_path}
|
|
write_file.store_string(new_content)
|
|
write_file.close()
|
|
|
|
return {&"ok": true, &"deleted": func_name}
|
|
|
|
|
|
func _internal_find_usages(args: Dictionary) -> Dictionary:
|
|
"""Find all usages of a variable, signal, or function across all scripts."""
|
|
var name: String = args.get(&"name", "")
|
|
var item_type: String = args.get(&"type", "") # "variable", "signal", "function"
|
|
var root_path: String = args.get(&"root", "res://")
|
|
|
|
if name.is_empty():
|
|
return {&"ok": false, &"error": "No name provided"}
|
|
|
|
# Collect all scripts
|
|
var script_paths: Array = []
|
|
_collect_scripts(root_path, script_paths, false)
|
|
|
|
var usages: Array = []
|
|
|
|
# Build regex pattern based on type
|
|
var pattern := RegEx.new()
|
|
if item_type == "signal":
|
|
# Match: signal_name.emit() or signal_name.connect() or .signal_name.
|
|
pattern.compile("\\b" + name + "\\b")
|
|
else:
|
|
# Match word boundary for variables and functions
|
|
pattern.compile("\\b" + name + "\\b")
|
|
|
|
for path: String in script_paths:
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
continue
|
|
|
|
var content: String = file.get_as_text()
|
|
file.close()
|
|
|
|
var lines: PackedStringArray = content.split("\n")
|
|
for i: int in range(lines.size()):
|
|
var line: String = lines[i]
|
|
if pattern.search(line):
|
|
# Skip the declaration line itself
|
|
if item_type == "variable" and RegEx.create_from_string("^\\s*(@export)?\\s*var\\s+" + name + "\\b").search(line):
|
|
continue
|
|
if item_type == "signal" and RegEx.create_from_string("^\\s*signal\\s+" + name + "\\b").search(line):
|
|
continue
|
|
if item_type == "function" and RegEx.create_from_string("^\\s*func\\s+" + name + "\\s*\\(").search(line):
|
|
continue
|
|
|
|
usages.append({
|
|
&"file": path,
|
|
&"line": i + 1,
|
|
&"code": line.strip_edges()
|
|
})
|
|
|
|
return {&"ok": true, &"usages": usages, &"count": usages.size()}
|
|
|
|
|
|
# Helper functions for file modification
|
|
|
|
func _build_var_line(name: String, type: String, default: String, exported: bool, onready: bool) -> String:
|
|
var line := ""
|
|
if exported:
|
|
line += "@export "
|
|
if onready:
|
|
line += "@onready "
|
|
line += "var " + name
|
|
if not type.is_empty():
|
|
line += ": " + type
|
|
if not default.is_empty():
|
|
line += " = " + default
|
|
return line
|
|
|
|
|
|
func _find_var_insert_position(lines: Array, exported: bool) -> int:
|
|
"""Find the best position to insert a new variable."""
|
|
var last_var_line := -1
|
|
var first_func_line := -1
|
|
var after_class_decl := 0
|
|
|
|
var re_var := RegEx.new()
|
|
re_var.compile("^(@export)?\\s*(@onready)?\\s*var\\s+")
|
|
var re_func := RegEx.new()
|
|
re_func.compile("^func\\s+")
|
|
var re_class := RegEx.new()
|
|
re_class.compile("^(class_name|extends)\\s+")
|
|
|
|
for i: int in range(lines.size()):
|
|
var stripped: String = lines[i].strip_edges()
|
|
if re_class.search(stripped):
|
|
after_class_decl = i + 1
|
|
if re_var.search(stripped):
|
|
last_var_line = i
|
|
if re_func.search(stripped) and first_func_line == -1:
|
|
first_func_line = i
|
|
break
|
|
|
|
# Insert after last variable, or before first function, or after class declarations
|
|
if last_var_line != -1:
|
|
return last_var_line + 1
|
|
if first_func_line != -1:
|
|
return first_func_line
|
|
return max(after_class_decl, 2) # At least after extends/class_name
|
|
|
|
|
|
func _find_signal_insert_position(lines: Array) -> int:
|
|
"""Find the best position to insert a new signal."""
|
|
var last_signal_line := -1
|
|
var first_var_line := -1
|
|
var after_class_decl := 0
|
|
|
|
var re_signal := RegEx.new()
|
|
re_signal.compile("^signal\\s+")
|
|
var re_var := RegEx.new()
|
|
re_var.compile("^(@export)?\\s*var\\s+")
|
|
var re_class := RegEx.new()
|
|
re_class.compile("^(class_name|extends)\\s+")
|
|
|
|
for i: int in range(lines.size()):
|
|
var stripped: String = lines[i].strip_edges()
|
|
if re_class.search(stripped):
|
|
after_class_decl = i + 1
|
|
if re_signal.search(stripped):
|
|
last_signal_line = i
|
|
if re_var.search(stripped) and first_var_line == -1:
|
|
first_var_line = i
|
|
|
|
# Insert after last signal, or before first var, or after class declarations
|
|
if last_signal_line != -1:
|
|
return last_signal_line + 1
|
|
if first_var_line != -1:
|
|
return first_var_line
|
|
return max(after_class_decl, 2)
|
|
|
|
|
|
# ============================================================================
|
|
# SCENE VISUALIZER INTERNAL FUNCTIONS
|
|
# These are called by the visualizer for scene view functionality
|
|
# ============================================================================
|
|
|
|
func _internal_get_scene_hierarchy(args: Dictionary) -> Dictionary:
|
|
"""Get scene hierarchy for visualizer."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("get_scene_hierarchy"):
|
|
return _scene_tools_ref.get_scene_hierarchy(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_get_scene_node_properties(args: Dictionary) -> Dictionary:
|
|
"""Get node properties for visualizer panel."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("get_scene_node_properties"):
|
|
return _scene_tools_ref.get_scene_node_properties(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_set_scene_node_property(args: Dictionary) -> Dictionary:
|
|
"""Set node property from visualizer panel."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("set_scene_node_property"):
|
|
return _scene_tools_ref.set_scene_node_property(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_add_scene_node(args: Dictionary) -> Dictionary:
|
|
"""Add a node to a scene from visualizer."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("add_node"):
|
|
return _scene_tools_ref.add_node(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_remove_scene_node(args: Dictionary) -> Dictionary:
|
|
"""Remove a node from a scene from visualizer."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("remove_node"):
|
|
return _scene_tools_ref.remove_node(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_rename_scene_node(args: Dictionary) -> Dictionary:
|
|
"""Rename a node in a scene from visualizer."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("rename_node"):
|
|
return _scene_tools_ref.rename_node(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_move_scene_node(args: Dictionary) -> Dictionary:
|
|
"""Move/reorder a node in a scene from visualizer."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("move_node"):
|
|
return _scene_tools_ref.move_node(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
# Simpler aliases for context menu actions
|
|
func _internal_add_node(args: Dictionary) -> Dictionary:
|
|
"""Add a node to a scene."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("add_node"):
|
|
return _scene_tools_ref.add_node(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_remove_node(args: Dictionary) -> Dictionary:
|
|
"""Remove a node from a scene."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("remove_node"):
|
|
return _scene_tools_ref.remove_node(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_rename_node(args: Dictionary) -> Dictionary:
|
|
"""Rename a node in a scene."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("rename_node"):
|
|
return _scene_tools_ref.rename_node(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_move_node(args: Dictionary) -> Dictionary:
|
|
"""Move/reorder a node in a scene."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("move_node"):
|
|
return _scene_tools_ref.move_node(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_duplicate_node(args: Dictionary) -> Dictionary:
|
|
"""Duplicate a node in a scene."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("duplicate_node"):
|
|
return _scene_tools_ref.duplicate_node(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|
|
|
|
|
|
func _internal_reorder_node(args: Dictionary) -> Dictionary:
|
|
"""Reorder a node within its parent (change sibling index)."""
|
|
if _scene_tools_ref and _scene_tools_ref.has_method("reorder_node"):
|
|
return _scene_tools_ref.reorder_node(args)
|
|
return {&"ok": false, &"error": "Scene tools not available"}
|