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>
293 lines
8.3 KiB
GDScript
293 lines
8.3 KiB
GDScript
@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()
|