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
-91
View File
@@ -1,91 +0,0 @@
Godot Jolt incorporates third-party material from the projects listed below.
Godot Engine (https://github.com/godotengine/godot)
Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md).
Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
godot-cpp (https://github.com/godot-jolt/godot-cpp)
Copyright (c) 2017-present Godot Engine contributors.
Copyright (c) 2022-present Mikael Hermansson.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
Jolt Physics (https://github.com/godot-jolt/jolt)
Copyright (c) 2021 Jorrit Rouwe.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
mimalloc (https://github.com/godot-jolt/mimalloc)
Copyright (c) 2018-2021 Microsoft Corporation, Daan Leijen.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
-41
View File
@@ -1,41 +0,0 @@
[godot-jolt]
version = "0.16.0-stable"
build = "8eca5bb795"
[configuration]
entry_symbol = "godot_jolt_main"
compatibility_minimum = "4.3"
compatibility_maximum = "4.7"
[libraries]
windows.release.single.x86_64 = "windows/godot-jolt_windows-x64.dll"
windows.debug.single.x86_64 = "windows/godot-jolt_windows-x64_editor.dll"
windows.release.single.x86_32 = "windows/godot-jolt_windows-x86.dll"
windows.debug.single.x86_32 = "windows/godot-jolt_windows-x86_editor.dll"
linux.release.single.x86_64 = "linux/godot-jolt_linux-x64.so"
linux.debug.single.x86_64 = "linux/godot-jolt_linux-x64_editor.so"
linux.release.single.x86_32 = "linux/godot-jolt_linux-x86.so"
linux.debug.single.x86_32 = "linux/godot-jolt_linux-x86_editor.so"
macos.release.single = "macos/godot-jolt_macos.framework"
macos.debug.single = "macos/godot-jolt_macos_editor.framework"
ios.release.single = "ios/godot-jolt_ios.framework"
ios.debug.single = "ios/godot-jolt_ios_editor.framework"
android.release.single.arm64 = "android/libgodot-jolt_android-arm64.so"
android.debug.single.arm64 = "android/libgodot-jolt_android-arm64_editor.so"
android.release.single.arm32 = "android/libgodot-jolt_android-arm32.so"
android.debug.single.arm32 = "android/libgodot-jolt_android-arm32_editor.so"
android.release.single.x86_64 = "android/libgodot-jolt_android-x64.so"
android.debug.single.x86_64 = "android/libgodot-jolt_android-x64_editor.so"
android.release.single.x86_32 = "android/libgodot-jolt_android-x86.so"
android.debug.single.x86_32 = "android/libgodot-jolt_android-x86_editor.so"
@@ -1 +0,0 @@
uid://bc7tkad04qybu
@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>godot-jolt_ios</string>
<key>CFBundleName</key>
<string>Godot Jolt</string>
<key>CFBundleDisplayName</key>
<string>Godot Jolt</string>
<key>CFBundleIdentifier</key>
<string>org.godot-jolt.godot-jolt</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
<key>CFBundleVersion</key>
<string>0.16.0</string>
<key>CFBundleShortVersionString</key>
<string>0.16.0</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>CSResourcesFileMapped</key>
<true/>
<key>DTPlatformName</key>
<string>iphoneos</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<!--
HACK(mihe): This is to work around a bug in Godot 4.3-beta1, where it treats Framework
bundles the same as XCFramework bundles, and expects there to be an `AvailableLibraries`
entry, which is really only a thing in XCFramework bundles. Note that we also lie about the
binary path having a `.dylib` extension in order for Godot to correctly identify this as a
dynamically linked bundle.
-->
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>godot-jolt_ios.dylib</string>
</dict>
</array>
</dict>
</plist>
@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>godot-jolt_ios_editor</string>
<key>CFBundleName</key>
<string>Godot Jolt</string>
<key>CFBundleDisplayName</key>
<string>Godot Jolt</string>
<key>CFBundleIdentifier</key>
<string>org.godot-jolt.godot-jolt</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
<key>CFBundleVersion</key>
<string>0.16.0</string>
<key>CFBundleShortVersionString</key>
<string>0.16.0</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>CSResourcesFileMapped</key>
<true/>
<key>DTPlatformName</key>
<string>iphoneos</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<!--
HACK(mihe): This is to work around a bug in Godot 4.3-beta1, where it treats Framework
bundles the same as XCFramework bundles, and expects there to be an `AvailableLibraries`
entry, which is really only a thing in XCFramework bundles. Note that we also lie about the
binary path having a `.dylib` extension in order for Godot to correctly identify this as a
dynamically linked bundle.
-->
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>godot-jolt_ios_editor.dylib</string>
</dict>
</array>
</dict>
</plist>
Binary file not shown.
Binary file not shown.
@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>godot-jolt_macos</string>
<key>CFBundleName</key>
<string>Godot Jolt</string>
<key>CFBundleDisplayName</key>
<string>Godot Jolt</string>
<key>CFBundleIdentifier</key>
<string>org.godot-jolt.godot-jolt</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
<key>CFBundleVersion</key>
<string>0.16.0</string>
<key>CFBundleShortVersionString</key>
<string>0.16.0</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CSResourcesFileMapped</key>
<true/>
<key>DTPlatformName</key>
<string>macosx</string>
<key>LSMinimumSystemVersion</key>
<string>10.12</string>
</dict>
</plist>
@@ -1,128 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict>
<key>Resources/Info.plist</key>
<data>
PLFiiedGCgBUVIdXAwIQZP3TzhU=
</data>
</dict>
<key>files2</key>
<dict>
<key>Resources/Info.plist</key>
<dict>
<key>hash2</key>
<data>
oUfCierc8a2c8bsFW1mu5lCv8dOCnLWbP/Ju8VP12gk=
</data>
</dict>
</dict>
<key>rules</key>
<dict>
<key>^Resources/</key>
<true/>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^.*</key>
<true/>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^[^/]+$</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>
@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>godot-jolt_macos_editor</string>
<key>CFBundleName</key>
<string>Godot Jolt</string>
<key>CFBundleDisplayName</key>
<string>Godot Jolt</string>
<key>CFBundleIdentifier</key>
<string>org.godot-jolt.godot-jolt</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
<key>CFBundleVersion</key>
<string>0.16.0</string>
<key>CFBundleShortVersionString</key>
<string>0.16.0</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CSResourcesFileMapped</key>
<true/>
<key>DTPlatformName</key>
<string>macosx</string>
<key>LSMinimumSystemVersion</key>
<string>10.12</string>
</dict>
</plist>
@@ -1,128 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict>
<key>Resources/Info.plist</key>
<data>
xxh2HxAbRksJ2pI8miqkcxNuMMY=
</data>
</dict>
<key>files2</key>
<dict>
<key>Resources/Info.plist</key>
<dict>
<key>hash2</key>
<data>
HuUpHzJ0lNjh0+cAW+2ottBM6fM0woHZDn2U83XmNzY=
</data>
</dict>
</dict>
<key>rules</key>
<dict>
<key>^Resources/</key>
<true/>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^.*</key>
<true/>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^[^/]+$</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>
Binary file not shown.
Binary file not shown.
+156
View File
@@ -0,0 +1,156 @@
@tool
extends Node
class_name MCPClient
## WebSocket client for communication with the MCP server.
## Handles connection, reconnection, and message routing.
signal connected
signal disconnected
signal tool_requested(request_id: String, tool_name: String, args: Dictionary)
const DEFAULT_URL := "ws://127.0.0.1:6505"
const RECONNECT_DELAY := 3.0
const MAX_RECONNECT_DELAY := 30.0
const MAX_PACKETS_PER_FRAME := 32
var socket: WebSocketPeer = WebSocketPeer.new()
var server_url: String = DEFAULT_URL
var _is_connected := false
var _reconnect_timer: Timer
var _current_reconnect_delay := RECONNECT_DELAY
var _should_reconnect := true
var _project_path: String
var _initialized := false
func _ready() -> void:
_project_path = ProjectSettings.globalize_path("res://")
# Create reconnect timer
_reconnect_timer = Timer.new()
_reconnect_timer.one_shot = true
_reconnect_timer.timeout.connect(_on_reconnect_timer)
add_child(_reconnect_timer)
_initialized = true
func _process(_delta: float) -> void:
if not _initialized:
return
if socket.get_ready_state() == WebSocketPeer.STATE_CLOSED:
if _is_connected:
_handle_disconnect()
return
socket.poll()
match socket.get_ready_state():
WebSocketPeer.STATE_OPEN:
if not _is_connected:
_handle_connect()
var packets_processed := 0
while socket.get_available_packet_count() > 0 and packets_processed < MAX_PACKETS_PER_FRAME:
_handle_message(socket.get_packet().get_string_from_utf8())
packets_processed += 1
WebSocketPeer.STATE_CLOSING:
pass # Wait for close
WebSocketPeer.STATE_CLOSED:
if _is_connected:
_handle_disconnect()
func connect_to_server(url: String = DEFAULT_URL) -> void:
server_url = url
_should_reconnect = true
_current_reconnect_delay = RECONNECT_DELAY
_attempt_connection()
func disconnect_from_server() -> void:
_should_reconnect = false
if _reconnect_timer:
_reconnect_timer.stop()
if socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
socket.close()
_is_connected = false
func _attempt_connection() -> void:
if socket.get_ready_state() != WebSocketPeer.STATE_CLOSED:
socket.close()
print("[MCP] Connecting to ", server_url, "...")
var err := socket.connect_to_url(server_url)
if err != OK:
push_error("[MCP] Failed to connect: ", err)
_schedule_reconnect()
func _handle_connect() -> void:
_is_connected = true
_current_reconnect_delay = RECONNECT_DELAY # Reset backoff
print("[MCP] Connected to server")
# Send godot_ready message with project info
_send_message({
&"type": &"godot_ready",
&"project_path": _project_path,
})
connected.emit()
func _handle_disconnect() -> void:
_is_connected = false
print("[MCP] Disconnected from server")
disconnected.emit()
if _should_reconnect:
_schedule_reconnect()
func _schedule_reconnect() -> void:
if not _reconnect_timer:
return
print("[MCP] Reconnecting in ", _current_reconnect_delay, " seconds...")
_reconnect_timer.start(_current_reconnect_delay)
# Exponential backoff
_current_reconnect_delay = min(_current_reconnect_delay * 2, MAX_RECONNECT_DELAY)
func _on_reconnect_timer() -> void:
_attempt_connection()
func _handle_message(json_string: String) -> void:
var message = JSON.parse_string(json_string)
if message == null:
push_error("[MCP] Failed to parse message: ", json_string)
return
var msg_type: String = message.get(&"type", "")
match msg_type:
"ping":
_send_message({&"type": &"pong"})
"tool_invoke":
var request_id: String = message.get(&"id", "")
var tool_name: String = message.get(&"tool", "")
var args: Dictionary = message.get(&"args", {})
print("[MCP] Tool request: ", tool_name, " (", request_id, ")")
tool_requested.emit(request_id, tool_name, args)
_:
print("[MCP] Unknown message type: ", msg_type)
func send_tool_result(request_id: String, success: bool, result = null, error: String = "") -> void:
var response := {
&"type": &"tool_result",
&"id": request_id,
&"success": success,
}
if success:
response[&"result"] = result
else:
response[&"error"] = error
_send_message(response)
print("[MCP] Sent result for ", request_id, " (success=", success, ")")
func _send_message(message: Dictionary) -> void:
if socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
socket.send_text(JSON.stringify(message))
func is_connected_to_server() -> bool:
return _is_connected
+1
View File
@@ -0,0 +1 @@
uid://baiaiot8klmbb
+7
View File
@@ -0,0 +1,7 @@
[plugin]
name="Godot MCP"
description="MCP server integration for AI assistants. Connects to the godot-mcp-server via WebSocket."
author="Godot MCP"
version="0.2.6"
script="plugin.gd"
+89
View File
@@ -0,0 +1,89 @@
@tool
extends EditorPlugin
## Godot MCP Plugin
## Connects to the godot-mcp-server via WebSocket and executes tools.
const MCPClientScript = preload("res://addons/godot_mcp/mcp_client.gd")
const ToolExecutorScript = preload("res://addons/godot_mcp/tool_executor.gd")
var _mcp_client: Node # MCPClient
var _tool_executor: Node # ToolExecutor
var _status_label: Label
func _enter_tree() -> void:
print("[Godot MCP] Plugin loading...")
# Create MCP client
_mcp_client = MCPClientScript.new()
_mcp_client.name = "MCPClient"
add_child(_mcp_client)
# Create tool executor
_tool_executor = ToolExecutorScript.new()
_tool_executor.name = "ToolExecutor"
add_child(_tool_executor) # _ready() runs here, creating child tools
_tool_executor.set_editor_plugin(self) # Now _visualizer_tools exists
# Connect signals
_mcp_client.connected.connect(_on_connected)
_mcp_client.disconnected.connect(_on_disconnected)
_mcp_client.tool_requested.connect(_on_tool_requested)
# Add status indicator to editor
_setup_status_indicator()
# Start connection
_mcp_client.connect_to_server()
print("[Godot MCP] Plugin loaded - connecting to MCP server...")
func _exit_tree() -> void:
print("[Godot MCP] Plugin unloading...")
if _mcp_client:
_mcp_client.disconnect_from_server()
_mcp_client.queue_free()
if _tool_executor:
_tool_executor.queue_free()
if _status_label:
remove_control_from_container(EditorPlugin.CONTAINER_TOOLBAR, _status_label)
_status_label.queue_free()
print("[Godot MCP] Plugin unloaded")
func _setup_status_indicator() -> void:
"""Add a small status label to the editor toolbar."""
_status_label = Label.new()
_status_label.text = "MCP: Connecting..."
_status_label.add_theme_color_override("font_color", Color.YELLOW)
_status_label.add_theme_font_size_override("font_size", 12)
add_control_to_container(EditorPlugin.CONTAINER_TOOLBAR, _status_label)
func _on_connected() -> void:
print("[Godot MCP] Connected to MCP server")
if _status_label:
_status_label.text = "MCP: Connected"
_status_label.add_theme_color_override("font_color", Color.GREEN)
func _on_disconnected() -> void:
print("[Godot MCP] Disconnected from MCP server")
if _status_label:
_status_label.text = "MCP: Disconnected"
_status_label.add_theme_color_override("font_color", Color.RED)
func _on_tool_requested(request_id: String, tool_name: String, args: Dictionary) -> void:
"""Handle incoming tool request from MCP server."""
print("[Godot MCP] Executing tool: ", tool_name)
# Execute the tool
var result: Dictionary = _tool_executor.execute_tool(tool_name, args)
var success: bool = result.get(&"ok", false)
if success:
result.erase(&"ok")
_mcp_client.send_tool_result(request_id, true, result)
else:
var error: String = result.get(&"error", "Unknown error")
_mcp_client.send_tool_result(request_id, false, null, error)
+1
View File
@@ -0,0 +1 @@
uid://delieuoth6q2q
+144
View File
@@ -0,0 +1,144 @@
@tool
extends Node
class_name ToolExecutor
## Routes tool invocations to the appropriate handler.
var _editor_plugin: EditorPlugin = null
var _file_tools: Node
var _scene_tools: Node
var _script_tools: Node
var _project_tools: Node
var _asset_tools: Node
var _visualizer_tools: Node
# Tool name → [handler_node, method_name]
var _tool_map: Dictionary = {}
var _initialized := false
func _init_tools() -> void:
"""Initialize all tool handlers. Called from set_editor_plugin."""
if _initialized:
return
_initialized = true
_file_tools = preload("res://addons/godot_mcp/tools/file_tools.gd").new()
_file_tools.name = "FileTools"
add_child(_file_tools)
_scene_tools = preload("res://addons/godot_mcp/tools/scene_tools.gd").new()
_scene_tools.name = "SceneTools"
add_child(_scene_tools)
_script_tools = preload("res://addons/godot_mcp/tools/script_tools.gd").new()
_script_tools.name = "ScriptTools"
add_child(_script_tools)
_project_tools = preload("res://addons/godot_mcp/tools/project_tools.gd").new()
_project_tools.name = "ProjectTools"
add_child(_project_tools)
_asset_tools = preload("res://addons/godot_mcp/tools/asset_tools.gd").new()
_asset_tools.name = "AssetTools"
add_child(_asset_tools)
_visualizer_tools = preload("res://addons/godot_mcp/tools/visualizer_tools.gd").new()
_visualizer_tools.name = "VisualizerTools"
add_child(_visualizer_tools)
# Build tool routing map
_tool_map = {
&"list_dir": [_file_tools, &"list_dir"],
&"read_file": [_file_tools, &"read_file"],
&"search_project": [_file_tools, &"search_project"],
&"create_script": [_file_tools, &"create_script"],
&"create_scene": [_scene_tools, &"create_scene"],
&"read_scene": [_scene_tools, &"read_scene"],
&"add_node": [_scene_tools, &"add_node"],
&"remove_node": [_scene_tools, &"remove_node"],
&"modify_node_property": [_scene_tools, &"modify_node_property"],
&"rename_node": [_scene_tools, &"rename_node"],
&"move_node": [_scene_tools, &"move_node"],
&"attach_script": [_scene_tools, &"attach_script"],
&"detach_script": [_scene_tools, &"detach_script"],
&"set_collision_shape": [_scene_tools, &"set_collision_shape"],
&"set_sprite_texture": [_scene_tools, &"set_sprite_texture"],
&"get_scene_hierarchy": [_scene_tools, &"get_scene_hierarchy"],
&"get_scene_node_properties": [_scene_tools, &"get_scene_node_properties"],
&"set_scene_node_property": [_scene_tools, &"set_scene_node_property"],
&"edit_script": [_script_tools, &"edit_script"],
&"validate_script": [_script_tools, &"validate_script"],
&"list_scripts": [_script_tools, &"list_scripts"],
&"create_folder": [_script_tools, &"create_folder"],
&"delete_file": [_script_tools, &"delete_file"],
&"rename_file": [_script_tools, &"rename_file"],
&"get_project_settings": [_project_tools, &"get_project_settings"],
&"list_settings": [_project_tools, &"list_settings"],
&"update_project_settings": [_project_tools, &"update_project_settings"],
&"get_input_map": [_project_tools, &"get_input_map"],
&"configure_input_map": [_project_tools, &"configure_input_map"],
&"get_collision_layers": [_project_tools, &"get_collision_layers"],
&"setup_autoload": [_project_tools, &"setup_autoload"],
&"get_node_properties": [_project_tools, &"get_node_properties"],
&"get_console_log": [_project_tools, &"get_console_log"],
&"get_errors": [_project_tools, &"get_errors"],
&"clear_console_log": [_project_tools, &"clear_console_log"],
&"open_in_godot": [_project_tools, &"open_in_godot"],
&"scene_tree_dump": [_project_tools, &"scene_tree_dump"],
&"generate_2d_asset": [_asset_tools, &"generate_2d_asset"],
&"map_project": [_visualizer_tools, &"map_project"],
&"map_scenes": [_visualizer_tools, &"map_scenes"],
}
func set_editor_plugin(plugin: EditorPlugin) -> void:
_editor_plugin = plugin
# Initialize tools first (must be done synchronously)
_init_tools()
# Pass editor plugin reference to all tool handlers
if _file_tools: _file_tools.set_editor_plugin(plugin)
if _scene_tools: _scene_tools.set_editor_plugin(plugin)
if _script_tools: _script_tools.set_editor_plugin(plugin)
if _project_tools: _project_tools.set_editor_plugin(plugin)
if _asset_tools: _asset_tools.set_editor_plugin(plugin)
if _visualizer_tools:
_visualizer_tools.set_editor_plugin(plugin)
# Pass scene_tools reference for visualizer internal scene functions
_visualizer_tools.set_scene_tools_ref(_scene_tools)
func execute_tool(tool_name: String, args: Dictionary) -> Dictionary:
"""Execute a tool by name with the given arguments."""
# Handle internal visualizer commands (not exposed as MCP tools)
if tool_name.begins_with("visualizer._internal_"):
var method: String = tool_name.replace("visualizer.", "")
if _visualizer_tools and _visualizer_tools.has_method(method):
return _visualizer_tools.call(method, args)
else:
return {&"ok": false, &"error": "Internal method not found: " + method}
if not _tool_map.has(tool_name):
return {
&"ok": false,
&"error": "Unknown tool: %s. Available: %s" % [tool_name, ", ".join(_tool_map.keys())]
}
var handler: Array = _tool_map[tool_name]
var node: Node = handler[0]
var method: StringName = handler[1]
if not node.has_method(method):
return {&"ok": false, &"error": "Tool handler not found: %s.%s" % [node.name, method]}
return node.call(method, args)
func get_available_tools() -> Array:
"""Return list of available tool names."""
return _tool_map.keys()
+1
View File
@@ -0,0 +1 @@
uid://dtvhy5tkh2hjv
+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
-31
View File
@@ -1,31 +0,0 @@
# Contributors
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
Studio], and the community. The following is the list of community contributors
involved with netfox:
* Alberto Klocker <albertok@gmail.com>
* Andrew Davis <jonandrewdavis@gmail.com>
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
* Dan Schuman <danschuman@gmail.com>
* Dustie <77035922+DustieDog@users.noreply.github.com>
* Eric Volpone <ericvolpone@gmail.com>
* Gordon MacPherson <gordon@gordonite.tech>
* Jake Cattrall <krazyjakee@gmail.com>
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
* Joseph Michael Ware <9at25jnr3@mozmail.com>
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
* Nicolas Batty <nicolas.batty@gmail.com>
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
* Russ <622740+russ-@users.noreply.github.com>
* Ryan Roden-Corrent <github@rcorre.net>
* TheYellowArchitect <dmalandris@uth.gr>
* TheYellowArchitect <hello@theyellowarchitect.com>
* camperotactico <alberto.vgdd@gmail.com>
* gk98s <89647115+gk98s@users.noreply.github.com>
* zibetnu <9at25jnr3@mozmail.com>
[netfox]: https://github.com/foxssake/netfox
[Fox's Sake Studio]: https://github.com/foxssake/
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bhm6yivew52yv"
path="res://.godot/imported/network-rigid-body-2d.svg-c7ef7df16c4383a80b842fa966aa7aea.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox.extras/icons/network-rigid-body-2d.svg"
dest_files=["res://.godot/imported/network-rigid-body-2d.svg-c7ef7df16c4383a80b842fa966aa7aea.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b2lri7ofmq6a7"
path="res://.godot/imported/network-rigid-body-3d.svg-325e3a4834ddab37f186912ac93fa449.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox.extras/icons/network-rigid-body-3d.svg"
dest_files=["res://.godot/imported/network-rigid-body-3d.svg-325e3a4834ddab37f186912ac93fa449.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cx8j75i6acsic"
path="res://.godot/imported/rewindable-state-machine.svg-87ca202891a7e2319363a5e2f5387494.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox.extras/icons/rewindable-state-machine.svg"
dest_files=["res://.godot/imported/rewindable-state-machine.svg-87ca202891a7e2319363a5e2f5387494.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dsxkv3ufy1r2q"
path="res://.godot/imported/rewindable-state.svg-b534e5a6f5de20b3fe0b63d612bd36bf.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox.extras/icons/rewindable-state.svg"
dest_files=["res://.godot/imported/rewindable-state.svg-b534e5a6f5de20b3fe0b63d612bd36bf.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
+7 -2
View File
@@ -109,7 +109,8 @@ func _enter_tree():
add_setting(setting)
for autoload in AUTOLOADS:
add_autoload_singleton(autoload.name, autoload.path)
if not has_autoload(autoload.name):
add_autoload_singleton(autoload.name, autoload.path)
for type in TYPES:
add_custom_type(type.name, type.base, load(type.script), load(type.icon))
@@ -122,7 +123,8 @@ func _exit_tree():
remove_setting(setting)
for autoload in AUTOLOADS:
remove_autoload_singleton(autoload.name)
if has_autoload(autoload.name):
remove_autoload_singleton(autoload.name)
for type in TYPES:
remove_custom_type(type.name)
@@ -148,6 +150,9 @@ func remove_setting(setting: Dictionary):
ProjectSettings.clear(setting.name)
func has_autoload(name: String) -> bool:
return ProjectSettings.has_setting("autoload/" + name)
func _render_tool_menu():
_free_tool_menu()
for driver_toggle in PhysicsDriverToggles.all():
@@ -1,18 +1,38 @@
extends PhysicsDriver
class_name RapierDriver2D
var _state: StateManager2D
var _stored_states: int = 0
func _init_physics_space() -> void:
physics_space = get_viewport().world_2d.space
PhysicsServer2D.space_set_active(physics_space, false)
_state = StateManager2D.new()
_state.root_node = self
_state.set_max_cache_length(ProjectSettings.get_setting("netfox/rollback/history_limit", 64))
_state.set_rolling_cache(true)
add_child(_state)
func _physics_step(delta) -> void:
RapierPhysicsServer2D.space_step(physics_space, delta)
RapierPhysicsServer2D.space_flush_queries(physics_space)
func _snapshot_space(tick: int) -> void:
snapshots[tick] = RapierPhysicsServer2D.export_binary(physics_space)
func _rollback_space(tick) -> void:
if snapshots.has(tick):
RapierPhysicsServer2D.import_binary(physics_space, snapshots[tick])
func _snapshot_space(tick: int) -> void:
_state.cache_state(physics_space, tick)
func _rollback_space(tick: int) -> void:
# With rolling cache, tick states are ordered by age with the newest at 0
var offset = NetworkTime.tick - tick
if (offset >= _stored_states):
return
_stored_states = min(_stored_states + 1, _state.max_cache_length)
_state.load_cached_state(physics_space, offset)
@@ -2,17 +2,36 @@ extends PhysicsDriver
class_name RapierDriver3D
var _state: StateManager3D
var _stored_states: int = 0
func _init_physics_space() -> void:
physics_space = get_viewport().world_3d.space
PhysicsServer3D.space_set_active(physics_space, false)
_state = StateManager3D.new()
_state.root_node = self
_state.set_max_cache_length(ProjectSettings.get_setting("netfox/rollback/history_limit", 64))
_state.set_rolling_cache(true)
add_child(_state)
func _physics_step(delta) -> void:
RapierPhysicsServer3D.space_step(physics_space, delta)
RapierPhysicsServer3D.space_flush_queries(physics_space)
func _snapshot_space(tick: int) -> void:
snapshots[tick] = RapierPhysicsServer3D.export_binary(physics_space)
func _rollback_space(tick) -> void:
if snapshots.has(tick):
RapierPhysicsServer3D.import_binary(physics_space, snapshots[tick])
func _snapshot_space(tick: int) -> void:
_state.cache_state(physics_space, tick)
func _rollback_space(tick: int) -> void:
# With rolling cache, tick states are ordered by age with the newest at 0
var offset = NetworkTime.tick - tick
if (offset >= _stored_states):
return
_stored_states = min(_stored_states + 1, _state.max_cache_length)
_state.load_cached_state(physics_space, offset)
+1 -1
View File
@@ -3,5 +3,5 @@
name="netfox.extras"
description="Game-specific utilities for Netfox"
author="Tamas Galffy and contributors"
version="1.35.3"
version="1.40.2"
script="netfox-extras.gd"
-31
View File
@@ -1,31 +0,0 @@
# Contributors
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
Studio], and the community. The following is the list of community contributors
involved with netfox:
* Alberto Klocker <albertok@gmail.com>
* Andrew Davis <jonandrewdavis@gmail.com>
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
* Dan Schuman <danschuman@gmail.com>
* Dustie <77035922+DustieDog@users.noreply.github.com>
* Eric Volpone <ericvolpone@gmail.com>
* Gordon MacPherson <gordon@gordonite.tech>
* Jake Cattrall <krazyjakee@gmail.com>
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
* Joseph Michael Ware <9at25jnr3@mozmail.com>
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
* Nicolas Batty <nicolas.batty@gmail.com>
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
* Russ <622740+russ-@users.noreply.github.com>
* Ryan Roden-Corrent <github@rcorre.net>
* TheYellowArchitect <dmalandris@uth.gr>
* TheYellowArchitect <hello@theyellowarchitect.com>
* camperotactico <alberto.vgdd@gmail.com>
* gk98s <89647115+gk98s@users.noreply.github.com>
* zibetnu <9at25jnr3@mozmail.com>
[netfox]: https://github.com/foxssake/netfox
[Fox's Sake Studio]: https://github.com/foxssake/
+95
View File
@@ -0,0 +1,95 @@
extends RefCounted
class_name _Bitset
# Stores a list of booleans, representing them efficiently as a PackedByteArray
var _data: PackedByteArray
var _bit_count: int
static func of_bools(values: Array) -> _Bitset:
var result := _Bitset.new(values.size())
for i in values.size():
if values[i]:
result.set_bit(i)
return result
func _init(bit_count: int):
var bytes := bit_count / 8
if bit_count % 8 > 0:
bytes += 1
_data = PackedByteArray()
_data.resize(bytes)
_bit_count = bit_count
func bit_count() -> int:
return _bit_count
func is_empty() -> bool:
return _bit_count == 0
func is_not_empty() -> bool:
return _bit_count != 0
func get_bit(idx: int) -> bool:
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
var byte_idx := idx / 8
var bit_idx := idx % 8
return (_data[byte_idx] >> bit_idx) & 0x1 != 0
func set_bit(idx: int) -> void:
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
var byte_idx := idx / 8
var bit_idx := idx % 8
_data[byte_idx] |= 0x1 << bit_idx
func clear_bit(idx: int) -> void:
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
var byte_idx := idx / 8
var bit_idx := idx % 8
_data[byte_idx] &= ~(0x1 << bit_idx)
func toggle_bit(idx: int) -> void:
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
var byte_idx := idx / 8
var bit_idx := idx % 8
_data[byte_idx] ^= 0x1 << bit_idx
func get_set_indices() -> Array[int]:
var result := [] as Array[int]
for i in _data.size():
var byte := _data[i]
if byte & 0x01: result.append(i * 8 + 0)
if byte & 0x02: result.append(i * 8 + 1)
if byte & 0x04: result.append(i * 8 + 2)
if byte & 0x08: result.append(i * 8 + 3)
if byte & 0x10: result.append(i * 8 + 4)
if byte & 0x20: result.append(i * 8 + 5)
if byte & 0x40: result.append(i * 8 + 6)
if byte & 0x80: result.append(i * 8 + 7)
return result
func equals(other) -> bool:
if other is _Bitset:
return other._bit_count == _bit_count and other._data == _data
else:
return false
func _to_string() -> String:
if is_empty():
return "Bitset(n=0)"
else:
var body := ""
for i in _bit_count:
if i != 0 and i % 4 == 0:
body += " "
if get_bit(i): body += "1"
else: body += "0"
return "Bitset(n=%d, %s)" % [_bit_count, body]
+1
View File
@@ -0,0 +1 @@
uid://c46ub48lgga12
+57
View File
@@ -0,0 +1,57 @@
extends RefCounted
class_name _Graph
# Represents a graph, in the sense of a set of nodes, arbitrarily connected by
# links
var _links_from := {} # `from` to `to[]`
var _links_to := {} # `to` to `from[]`
func link(from: Variant, to: Variant) -> void:
if has_link(from, to):
return
_append(_links_from, from, to)
_append(_links_to, to, from)
func unlink(from: Variant, to: Variant) -> void:
_erase(_links_from, from, to)
_erase(_links_to, to, from)
func erase(node: Variant) -> void:
var links_to := _links_from.get(node, [])
var links_from := _links_to.get(node, [])
_links_from.erase(node)
_links_to.erase(node)
for link in links_to:
_erase(_links_to, link, node)
for link in links_from:
_erase(_links_from, link, node)
func get_linked_from(from: Variant) -> Array:
return _links_from.get(from, [])
func get_linked_to(to: Variant) -> Array:
return _links_to.get(to, [])
func has_link(from: Variant, to: Variant) -> bool:
return get_linked_from(from).has(to)
func _append(pool: Dictionary, key: Variant, value: Variant) -> void:
if not pool.has(key):
pool[key] = [value]
else:
pool[key].append(value)
func _erase(pool: Dictionary, key: Variant, value: Variant) -> void:
if not pool.has(key):
return
var values := pool[key] as Array
values.erase(value)
if values.is_empty():
pool.erase(key)
+1
View File
@@ -0,0 +1 @@
uid://djknl4n6akxue
+113 -49
View File
@@ -1,70 +1,134 @@
extends RefCounted
class_name _HistoryBuffer
# Maps ticks (int) to arbitrary data
var _buffer: Dictionary = {}
# Maps ticks (int) to arbitrary data, stored in a sliding ring buffer
func get_snapshot(tick: int):
if _buffer.has(tick):
return _buffer[tick]
else:
return null
var _capacity := 64
var _buffer := []
var _previous := []
func set_snapshot(tick: int, data):
_buffer[tick] = data
var _tail := 0
var _head := 0
func get_buffer() -> Dictionary:
return _buffer
static func of(capacity: int, data: Dictionary) -> _HistoryBuffer:
var history_buffer := _HistoryBuffer.new(capacity)
for idx in data:
history_buffer.set_at(idx, data[idx])
return history_buffer
func get_earliest_tick() -> int:
return _buffer.keys().min()
func _init(capacity: int = 64):
_capacity = capacity
_buffer.resize(_capacity)
_previous.resize(_capacity)
func get_latest_tick() -> int:
return _buffer.keys().max()
func duplicate(deep: bool = false) -> _HistoryBuffer:
var buffer := _HistoryBuffer.new(_capacity)
func get_closest_tick(tick: int) -> int:
if _buffer.has(tick):
return tick
buffer._buffer = _buffer.duplicate(deep)
buffer._previous = _previous.duplicate()
buffer._tail = _tail
buffer._head = _head
if _buffer.is_empty():
return -1
return buffer
var earliest_tick = get_earliest_tick()
if tick < earliest_tick:
return earliest_tick
func push(value: Variant) -> void:
_buffer[_head % _capacity] = value
_previous[_head % _capacity] = _head
_head += 1
_tail += maxi(0, size() - capacity())
var latest_tick = get_latest_tick()
if tick > latest_tick:
return latest_tick
func pop() -> Variant:
assert(is_not_empty(), "History buffer is empty!")
return _buffer.keys() \
.filter(func (key): return key < tick) \
.max()
var value = _buffer[_tail % _capacity]
_tail += 1
return value
func get_history(tick: int):
var closest_tick = get_closest_tick(tick)
return _buffer.get(closest_tick)
func set_at(at: int, value: Variant) -> void:
# Why does this need so many branches?
if is_empty():
# Buffer is empty, jump to specified index
_tail = at
_head = at
push(value)
elif at < _head - capacity():
# Trying to set something that would wrap back around and overwrite
# current data
return
elif at == _head:
# Simply adding a new item
push(value)
elif at < _head:
_buffer[at % _capacity] = value
# Update prev-buffer
for i in range(at, _head):
if _previous[i % _capacity] == i:
break
_previous[i % _capacity] = at
_tail = mini(_tail, at)
elif at >= _head + _capacity:
# We're leaving all data behind
_tail = at
_head = at
func trim(earliest_tick_to_keep: int):
var ticks := _buffer.keys()
for tick in ticks:
if tick < earliest_tick_to_keep:
_buffer.erase(tick)
_previous.fill(null)
_buffer.fill(null)
func clear():
_buffer.clear()
push(value)
elif at >= _head:
# Skipping forward a bit
var previous := _head - 1
while _head < at:
_previous[_head % _capacity] = previous
_head += 1
_tail += maxi(0, size() - _capacity)
push(value)
func has_at(at: int) -> bool:
if is_empty(): return false
if at < _head - capacity(): return false
if at >= _head: return false
return _previous[at % _capacity] == at
func get_at(at: int, default: Variant = null) -> Variant:
if not has_at(at):
return default
return _buffer[at % _capacity]
func has_latest_at(at: int) -> bool:
if is_empty(): return false
if at < _tail: return false
return true
func size() -> int:
return _buffer.size()
return _head - _tail
func capacity() -> int:
return _capacity
func get_earliest_index() -> int:
return _tail
func get_latest_index() -> int:
return _head - 1
func get_latest_index_at(at: int) -> int:
if not has_latest_at(at):
return -1
if at >= _head:
return get_latest_index()
return _previous[at % _capacity]
func get_latest_at(at: int) -> Variant:
return get_at(get_latest_index_at(at))
func clear():
_tail = _head
func is_empty() -> bool:
return _buffer.is_empty()
return size() == 0
func has(tick) -> bool:
return _buffer.has(tick)
func ticks() -> Array:
return _buffer.keys()
func erase(tick):
_buffer.erase(tick)
func is_not_empty() -> bool:
return not is_empty()
@@ -0,0 +1,22 @@
extends RefCounted
class_name _IntervalScheduler
# Returns true on every nth `is_now()` call
var interval := 1
var _idx := 0
func _init(p_interval: int = 1):
interval = p_interval
func is_now() -> bool:
if interval <= 0:
return false
elif interval == 1:
return true
elif _idx + 1 >= interval:
_idx = 0
return true
else:
_idx += 1
return false
@@ -0,0 +1 @@
uid://egfuyvoj0r2s
+1 -1
View File
@@ -3,5 +3,5 @@
name="netfox.internals"
description="Shared internals for netfox addons"
author="Tamas Galffy and contributors"
version="1.35.3"
version="1.40.2"
script="plugin.gd"
+8
View File
@@ -10,6 +10,11 @@ static func of(items: Array) -> _Set:
result.add(item)
return result
func duplicate(deep: bool = false) -> _Set:
var result := _Set.new()
result._data = _data.duplicate(deep)
return result
func add(value):
_data[value] = true
@@ -46,6 +51,9 @@ func equals(other) -> bool:
func _to_string():
return "Set" + str(values())
func _to_vest():
return _data.keys()
func _iter_init(arg) -> bool:
_iterator_idx = 0
return _can_iterate()
-31
View File
@@ -1,31 +0,0 @@
# Contributors
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
Studio], and the community. The following is the list of community contributors
involved with netfox:
* Alberto Klocker <albertok@gmail.com>
* Andrew Davis <jonandrewdavis@gmail.com>
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
* Dan Schuman <danschuman@gmail.com>
* Dustie <77035922+DustieDog@users.noreply.github.com>
* Eric Volpone <ericvolpone@gmail.com>
* Gordon MacPherson <gordon@gordonite.tech>
* Jake Cattrall <krazyjakee@gmail.com>
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
* Joseph Michael Ware <9at25jnr3@mozmail.com>
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
* Nicolas Batty <nicolas.batty@gmail.com>
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
* Russ <622740+russ-@users.noreply.github.com>
* Ryan Roden-Corrent <github@rcorre.net>
* TheYellowArchitect <dmalandris@uth.gr>
* TheYellowArchitect <hello@theyellowarchitect.com>
* camperotactico <alberto.vgdd@gmail.com>
* gk98s <89647115+gk98s@users.noreply.github.com>
* zibetnu <9at25jnr3@mozmail.com>
[netfox]: https://github.com/foxssake/netfox
[Fox's Sake Studio]: https://github.com/foxssake/
+1 -1
View File
@@ -20,7 +20,7 @@ See the root [README](../../README.md).
## Usage
See the [docs](https://foxssake.github.io/netfox/netfox.noray/guides/noray/).
See the [docs](https://foxssake.github.io/netfox/latest/netfox.noray/guides/noray/).
For a full example, see [noray-bootstrapper.gd].
+7 -2
View File
@@ -23,7 +23,8 @@ func _enter_tree():
add_setting(setting)
for autoload in AUTOLOADS:
add_autoload_singleton(autoload.name, autoload.path)
if not has_autoload(autoload.name):
add_autoload_singleton(autoload.name, autoload.path)
func _exit_tree():
if ProjectSettings.get_setting("netfox/general/clear_settings", false):
@@ -31,7 +32,8 @@ func _exit_tree():
remove_setting(setting)
for autoload in AUTOLOADS:
remove_autoload_singleton(autoload.name)
if has_autoload(autoload.name):
remove_autoload_singleton(autoload.name)
func add_setting(setting: Dictionary):
if ProjectSettings.has_setting(setting.name):
@@ -51,3 +53,6 @@ func remove_setting(setting: Dictionary):
return
ProjectSettings.clear(setting.name)
func has_autoload(name: String) -> bool:
return ProjectSettings.has_setting("autoload/" + name)
+1 -1
View File
@@ -3,5 +3,5 @@
name="netfox.noray"
description="Bulletproof your connectivity with noray integration for netfox"
author="Tamas Galffy and contributors"
version="1.35.3"
version="1.40.2"
script="netfox-noray.gd"
-31
View File
@@ -1,31 +0,0 @@
# Contributors
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
Studio], and the community. The following is the list of community contributors
involved with netfox:
* Alberto Klocker <albertok@gmail.com>
* Andrew Davis <jonandrewdavis@gmail.com>
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
* Dan Schuman <danschuman@gmail.com>
* Dustie <77035922+DustieDog@users.noreply.github.com>
* Eric Volpone <ericvolpone@gmail.com>
* Gordon MacPherson <gordon@gordonite.tech>
* Jake Cattrall <krazyjakee@gmail.com>
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
* Joseph Michael Ware <9at25jnr3@mozmail.com>
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
* Nicolas Batty <nicolas.batty@gmail.com>
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
* Russ <622740+russ-@users.noreply.github.com>
* Ryan Roden-Corrent <github@rcorre.net>
* TheYellowArchitect <dmalandris@uth.gr>
* TheYellowArchitect <hello@theyellowarchitect.com>
* camperotactico <alberto.vgdd@gmail.com>
* gk98s <89647115+gk98s@users.noreply.github.com>
* zibetnu <9at25jnr3@mozmail.com>
[netfox]: https://github.com/foxssake/netfox
[Fox's Sake Studio]: https://github.com/foxssake/
@@ -1,136 +0,0 @@
extends RefCounted
class_name _DiffHistoryEncoder
var _history: _PropertyHistoryBuffer
var _property_cache: PropertyCache
var _full_snapshot := {}
var _encoded_snapshot := {}
var _property_indexes := _BiMap.new()
var _version := 0
var _has_received := false
static var _logger := NetfoxLogger._for_netfox("DiffHistoryEncoder")
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
_history = p_history
_property_cache = p_property_cache
func add_properties(properties: Array[PropertyEntry]) -> void:
var has_new_properties := false
for property_entry in properties:
var is_new := _ensure_property_idx(property_entry.to_string())
has_new_properties = has_new_properties or is_new
# If we added any new properties, increment version
if has_new_properties:
_version = (_version + 1) % 256
func encode(tick: int, reference_tick: int, properties: Array[PropertyEntry]) -> PackedByteArray:
assert(properties.size() <= 255, "Property indices may not fit into bytes!")
var snapshot := _history.get_snapshot(tick)
var property_strings := properties.map(func(it): return it.to_string())
var reference_snapshot := _history.get_history(reference_tick)
var diff_snapshot := reference_snapshot.make_patch(snapshot)
_full_snapshot = snapshot.as_dictionary()
_encoded_snapshot = diff_snapshot.as_dictionary()
if diff_snapshot.is_empty():
return PackedByteArray()
var buffer := StreamPeerBuffer.new()
buffer.put_u8(_version)
for property in diff_snapshot.properties():
var property_idx := _property_indexes.get_by_value(property) as int
var property_value = diff_snapshot.get_value(property)
buffer.put_u8(property_idx)
buffer.put_var(property_value)
return buffer.data_array
func decode(data: PackedByteArray, properties: Array[PropertyEntry]) -> _PropertySnapshot:
var result := _PropertySnapshot.new()
if data.is_empty():
return result
var buffer := StreamPeerBuffer.new()
buffer.data_array = data
var packet_version := buffer.get_u8()
if packet_version != _version:
if not _has_received:
# This is the first time we receive data
# Assume the version is OK
_version = packet_version
else:
# Since we don't remove entries, only add, we can still parse what
# we can
_logger.warning("Property config version mismatch - own %d != received %d", [_version, packet_version])
_has_received = true
while buffer.get_available_bytes() > 0:
var property_idx := buffer.get_u8()
var property_value := buffer.get_var()
if not _property_indexes.has_key(property_idx):
_logger.warning("Received unknown property index %d, ignoring!", [property_idx])
continue
var property_entry := _property_indexes.get_by_key(property_idx)
result.set_value(property_entry, property_value)
return result
func apply(tick: int, snapshot: _PropertySnapshot, reference_tick: int, sender: int = -1) -> bool:
if tick < NetworkRollback.history_start:
# State too old!
_logger.error(
"Received diff snapshot for @%d, rejecting because older than %s frames",
[tick, NetworkRollback.history_limit]
)
return false
if snapshot.is_empty():
return true
if sender > 0:
snapshot.sanitize(sender, _property_cache)
if snapshot.is_empty():
_logger.warning("Received invalid diff from #%s for @%s", [sender, tick])
return false
if not _history.has(reference_tick):
# Reference tick missing, hope for the best
_logger.warning("Reference tick %d missing for #%s applying %d", [reference_tick, sender, tick])
var reference_snapshot := _history.get_snapshot(reference_tick)
_history.set_snapshot(tick, reference_snapshot.merge(snapshot))
return true
# TODO: Rework metrics so these are not needed
func get_encoded_snapshot() -> Dictionary:
return _encoded_snapshot
func get_full_snapshot() -> Dictionary:
return _full_snapshot
func _ensure_property_idx(property: String) -> bool:
if _property_indexes.has_value(property):
return false
assert(_property_indexes.size() < 256, "Property index map is full, can't add new property!")
var idx := hash(property) % 256
while _property_indexes.has_key(idx):
idx = hash(idx + 1) % 256
_property_indexes.put(idx, property)
return true
@@ -1 +0,0 @@
uid://dc73evbedbmvs
@@ -1,116 +0,0 @@
extends RefCounted
class_name _RedundantHistoryEncoder
var redundancy: int = 4:
get = get_redundancy,
set = set_redundancy
var _history: _PropertyHistoryBuffer
var _properties: Array[PropertyEntry]
var _property_cache: PropertyCache
var _version := 0
var _has_received := false
var _logger := NetfoxLogger._for_netfox("RedundantHistoryEncoder")
func get_redundancy() -> int:
return redundancy
func set_redundancy(p_redundancy: int):
if p_redundancy <= 0:
_logger.warning(
"Attempting to set redundancy to %d, which would send no data!", [p_redundancy]
)
return
redundancy = p_redundancy
func set_properties(properties: Array[PropertyEntry]) -> void:
if _properties != properties:
_version = (_version + 1) % 256
_properties = properties.duplicate()
func encode(tick: int, properties: Array[PropertyEntry]) -> Array:
if _history.is_empty():
return []
var data := []
for i in range(mini(redundancy, _history.size())):
var offset_tick := tick - i
if offset_tick < _history.get_earliest_tick():
break
var snapshot := _history.get_snapshot(offset_tick)
for property in properties:
data.append(snapshot.get_value(property.to_string()))
data.append(_version)
return data
func decode(data: Array, properties: Array[PropertyEntry]) -> Array[_PropertySnapshot]:
if data.is_empty() or properties.is_empty():
return []
var packet_version := data.pop_back() as int
if packet_version != _version:
if not _has_received:
# First packet, assume version is OK
_version = packet_version
else:
# Version mismatch, can't parse
_logger.warning("Version mismatch! own: %d, received: %s", [_version, packet_version])
return []
var result: Array[_PropertySnapshot] = []
var redundancy := data.size() / properties.size()
result.assign(range(redundancy)
.map(func(__): return _PropertySnapshot.new())
)
for i in range(data.size()):
var offset_idx := i / properties.size()
var prop_idx := i % properties.size()
result[offset_idx].set_value(properties[prop_idx].to_string(), data[i])
_has_received = true
return result
# Returns earliest new tick as int, or -1 if no new ticks applied
func apply(tick: int, snapshots: Array[_PropertySnapshot], sender: int = 0) -> int:
var earliest_new_tick = -1
for i in range(snapshots.size()):
var offset_tick := tick - i
var snapshot := snapshots[i]
if offset_tick < NetworkRollback.history_start:
# Data too old
_logger.warning(
"Received data for %s, rejecting because older than %s frames",
[offset_tick, NetworkRollback.history_limit]
)
continue
if sender > 0:
snapshot.sanitize(sender, _property_cache)
if snapshot.is_empty():
# No valid properties ( probably after sanitize )
_logger.warning("Received invalid data from %d for tick %d", [sender, tick])
continue
var known_snapshot := _history.get_snapshot(offset_tick)
if not known_snapshot.equals(snapshot):
# Received a new snapshot, store and emit signal
_history.set_snapshot(offset_tick, snapshot)
earliest_new_tick = offset_tick
return earliest_new_tick
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
_history = p_history
_property_cache = p_property_cache
@@ -1 +0,0 @@
uid://ytn07qahpatv
@@ -1,67 +0,0 @@
extends RefCounted
class_name _SnapshotHistoryEncoder
var _history: _PropertyHistoryBuffer
var _property_cache: PropertyCache
var _properties: Array[PropertyEntry]
var _version := -1
var _has_received := false
static var _logger := NetfoxLogger._for_netfox("_SnapshotHistoryEncoder")
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
_history = p_history
_property_cache = p_property_cache
func set_properties(properties: Array[PropertyEntry]) -> void:
if _properties != properties:
_version = (_version + 1) % 256
_properties = properties.duplicate()
func encode(tick: int, properties: Array[PropertyEntry]) -> Array:
var snapshot := _history.get_snapshot(tick)
var data := []
data.resize(properties.size())
for i in range(properties.size()):
data[i] = snapshot.get_value(properties[i].to_string())
data.append(_version)
return data
func decode(data: Array, properties: Array[PropertyEntry]) -> _PropertySnapshot:
var result := _PropertySnapshot.new()
var packet_version = data.pop_back()
if packet_version != _version:
if not _has_received:
# First packet, assume version is OK
_version = packet_version
else:
# Version mismatch, can't parse
_logger.warning("Version mismatch! own: %d, received: %s", [_version, packet_version])
return result
if properties.size() != data.size():
_logger.warning("Received snapshot with %d entries, with %d known - parsing as much as possible", [data.size(), properties.size()])
for i in range(0, mini(data.size(), properties.size())):
result.set_value(properties[i].to_string(), data[i])
_has_received = true
return result
func apply(tick: int, snapshot: _PropertySnapshot, sender: int = -1) -> bool:
if tick < NetworkRollback.history_start:
# State too old!
_logger.error("Received full snapshot for %s, rejecting because older than %s frames", [tick, NetworkRollback.history_limit])
return false
if sender > 0:
snapshot.sanitize(sender, _property_cache)
if snapshot.is_empty(): return false
_history.set_snapshot(tick, snapshot)
return true
@@ -1 +0,0 @@
uid://dwhlghjsgdy4o
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 16 16"
xml:space="preserve"
sodipodi:docname="predictive-synchronizer.svg"
width="16"
height="16"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1"><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect2"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,1,0,1 @ F,0,0,1,0,1,0,1 @ F,0,0,1,0,1,0,1 @ F,0,0,1,0,1,0,1"
radius="1"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect1"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /></defs><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="24.40625"
inkscape:cx="8.8297055"
inkscape:cy="10.140845"
inkscape:window-width="2560"
inkscape:window-height="1368"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"><inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14902"
empspacing="5"
enabled="true"
visible="false" /></sodipodi:namedview>
<style
type="text/css"
id="style1">
.st0{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;}
.st1{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.66;}
.st2{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33;}
.st3{fill:#5FFF97;}
.st4{fill:#FF5F5F;}
</style>
<path
id="path6"
class="st0"
d="M 8,8 10.425,5.575"
style="stroke-width:2;stroke-dasharray:none" />
<path
id="path7"
class="st1"
d="M 8,8 V 4.575"
style="stroke-width:2;stroke-dasharray:none" />
<path
id="path8"
class="st2"
d="M 8,8 5.575,5.575"
style="stroke-width:2;stroke-dasharray:none" />
<circle
id="path4"
class="st0"
cx="8"
cy="8"
style="stroke-width:2;stroke-dasharray:none"
r="5" />
<path
style="fill:#6f8a91"
id="rect1"
width="8"
height="1.8"
x="4.4037995"
y="11.704836"
sodipodi:type="rect"
d="M 5.4037995,11.704836 H 11.4038 a 1,1 45 0 1 1,1 0.90553849,0.90553849 141.34019 0 1 -1,0.8 H 5.4037995 a 1,1 45 0 1 -1,-1 0.90553849,0.90553849 141.34019 0 1 1,-0.8 z"
inkscape:path-effect="#path-effect2"
transform="translate(-0.40379953,-2.1992974)" /></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dsr2mjcr1lh6v"
path="res://.godot/imported/predictive-synchronizer.svg-c1a256fe714777cb768d64b354a43e2b.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox/icons/predictive-synchronizer.svg"
dest_files=["res://.godot/imported/predictive-synchronizer.svg-c1a256fe714777cb768d64b354a43e2b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dy3qj4jbh0enm"
path="res://.godot/imported/rewindable-action.svg-2eb32b564a2fe4ecb8b77ec5b55683ab.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox/icons/rewindable-action.svg"
dest_files=["res://.godot/imported/rewindable-action.svg-2eb32b564a2fe4ecb8b77ec5b55683ab.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://csg26ysqqb4xe"
path="res://.godot/imported/rollback-synchronizer.svg-99c6071e1009de5a35a481b2f486c380.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox/icons/rollback-synchronizer.svg"
dest_files=["res://.godot/imported/rollback-synchronizer.svg-99c6071e1009de5a35a481b2f486c380.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ogbi1hffcoyh"
path="res://.godot/imported/state-synchronizer.svg-9cb9447ba79f114a58e468a24d17b860.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox/icons/state-synchronizer.svg"
dest_files=["res://.godot/imported/state-synchronizer.svg-9cb9447ba79f114a58e468a24d17b860.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cpaqxcohxtb68"
path="res://.godot/imported/tick-interpolator.svg-c60124cd7d287f516c89a6022efef330.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox/icons/tick-interpolator.svg"
dest_files=["res://.godot/imported/tick-interpolator.svg-c60124cd7d287f516c89a6022efef330.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
+73 -7
View File
@@ -10,6 +10,11 @@ var SETTINGS: Array[Dictionary] = [
"value": true,
"type": TYPE_BOOL
},
{
"name": "netfox/general/use_raw_commands",
"value": false,
"type": TYPE_BOOL
},
# Logging
NetfoxLogger._make_setting("netfox/logging/netfox_log_level"),
# Time settings
@@ -112,11 +117,41 @@ var SETTINGS: Array[Dictionary] = [
"hint": PROPERTY_HINT_RANGE,
"hint_string": "0,4,or_greater"
},
{
"name": "netfox/rollback/enable_input_broadcast",
"value": false,
"type": TYPE_BOOL
},
{
"name": "netfox/rollback/enable_diff_states",
"value": true,
"type": TYPE_BOOL
},
{
"name": "netfox/rollback/full_state_interval",
"value": 24,
"type": TYPE_INT,
"hint": PROPERTY_HINT_RANGE,
"hint_string": "0,60,or_greater"
},
# StateSynchronizer
{
"name": "netfox/state_synchronizer/enable_diff_states",
"value": true,
"type": TYPE_BOOL
},
{
"name": "netfox/state_synchronizer/history_limit",
"value": 64,
"type": TYPE_INT
},
{
"name": "netfox/state_synchronizer/full_state_interval",
"value": 24,
"type": TYPE_INT,
"hint": PROPERTY_HINT_RANGE,
"hint_string": "0,60,or_greater"
},
# Events
{
"name": "netfox/events/enabled",
@@ -145,6 +180,26 @@ const AUTOLOADS: Array[Dictionary] = [
{
"name": "NetworkPerformance",
"path": ROOT + "/network-performance.gd"
},
{
"name": "RollbackSimulationServer",
"path": ROOT + "/servers/rollback-simulation-server.gd"
},
{
"name": "NetworkHistoryServer",
"path": ROOT + "/servers/network-history-server.gd"
},
{
"name": "NetworkSynchronizationServer",
"path": ROOT + "/servers/network-synchronization-server.gd"
},
{
"name": "NetworkIdentityServer",
"path": ROOT + "/servers/network-identity-server.gd"
},
{
"name": "NetworkCommandServer",
"path": ROOT + "/servers/network-command-server.gd"
}
]
@@ -173,15 +228,22 @@ const TYPES: Array[Dictionary] = [
"script": ROOT + "/rewindable-action.gd",
"icon": ROOT + "/icons/rewindable-action.svg"
},
{
"name": "PredictiveSynchronizer",
"base": "Node",
"script": ROOT + "/rollback/predictive-synchronizer.gd",
"icon": ROOT + "/icons/predictive-synchronizer.svg"
},
]
func _enter_tree():
for setting in SETTINGS:
add_setting(setting)
for autoload in AUTOLOADS:
add_autoload_singleton(autoload.name, autoload.path)
if not has_autoload(autoload.name):
add_autoload_singleton(autoload.name, autoload.path)
for type in TYPES:
add_custom_type(type.name, type.base, load(type.script), load(type.icon))
@@ -189,10 +251,11 @@ func _exit_tree() -> void:
if ProjectSettings.get_setting(&"netfox/general/clear_settings", false):
for setting in SETTINGS:
remove_setting(setting)
for autoload in AUTOLOADS:
remove_autoload_singleton(autoload.name)
if has_autoload(autoload.name):
remove_autoload_singleton(autoload.name)
for type in TYPES:
remove_custom_type(type.name)
@@ -212,5 +275,8 @@ func add_setting(setting: Dictionary) -> void:
func remove_setting(setting: Dictionary) -> void:
if not ProjectSettings.has_setting(setting.name):
return
ProjectSettings.clear(setting.name)
func has_autoload(name: String) -> bool:
return ProjectSettings.has_setting("autoload/" + name)
+6
View File
@@ -106,12 +106,18 @@ func get_sent_state_props_ratio() -> float:
func push_full_state(state: Dictionary) -> void:
_full_state_props_accum += state.size()
func push_full_state_props(count: int) -> void:
_full_state_props_accum += count
func push_full_state_broadcast(state: Dictionary) -> void:
_full_state_props_accum += state.size() * (multiplayer.get_peers().size() - 1)
func push_sent_state(state: Dictionary) -> void:
_sent_state_props_accum += state.size()
func push_sent_state_props(count: int) -> void:
_sent_state_props_accum += count
func push_sent_state_broadcast(state: Dictionary) -> void:
_sent_state_props_accum += state.size() * (multiplayer.get_peers().size() - 1)
+22 -13
View File
@@ -115,6 +115,11 @@ var _offset: float = 0.
var _rtt: float = 0.
var _rtt_jitter: float = 0.
@onready var _cmd_ping := NetworkCommandServer.register_command(_handle_ping, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
@onready var _cmd_pong := NetworkCommandServer.register_command(_handle_pong, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
@onready var _cmd_req_time := NetworkCommandServer.register_command(_handle_request_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
@onready var _cmd_set_time := NetworkCommandServer.register_command(_handle_set_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
## Emitted after the initial time sync.
##
## At the start of the game, clients request an initial timestamp to kickstart
@@ -145,7 +150,7 @@ func start() -> void:
_sample_idx = 0
_sample_buffer = _RingBuffer.new(sync_samples)
_request_timestamp.rpc_id(1)
_cmd_req_time.send(PackedByteArray(), 1)
## Stop the time synchronization loop.
func stop() -> void:
@@ -169,7 +174,7 @@ func _loop() -> void:
_awaiting_samples[_sample_idx] = sample
sample.ping_sent = _clock.get_time()
_send_ping.rpc_id(1, _sample_idx)
_cmd_ping.send(var_to_bytes(_sample_idx), 1)
_sample_idx += 1
@@ -235,15 +240,19 @@ func _discipline_clock() -> void:
_offset = offset - nudge
@rpc("any_peer", "call_remote", "unreliable")
func _send_ping(idx: int) -> void:
func _handle_ping(sender: int, data: PackedByteArray) -> void:
var idx := bytes_to_var(data) as int
var ping_received := _clock.get_time()
var sender := multiplayer.get_remote_sender_id()
_send_pong.rpc_id(sender, idx, ping_received, _clock.get_time())
_cmd_pong.send(var_to_bytes([idx, ping_received, _clock.get_time()]), sender)
func _handle_pong(sender: int, data: PackedByteArray) -> void:
var args := bytes_to_var(data)
var idx := args[0] as int
var ping_received := args[1] as float
var pong_sent := args[2] as float
@rpc("any_peer", "call_remote", "unreliable")
func _send_pong(idx: int, ping_received: float, pong_sent: float) -> void:
var pong_received := _clock.get_time()
if not _awaiting_samples.has(idx):
@@ -264,13 +273,13 @@ func _send_pong(idx: int, ping_received: float, pong_sent: float) -> void:
# Discipline clock based on new sample
_discipline_clock()
@rpc("any_peer", "call_remote", "reliable")
func _request_timestamp() -> void:
func _handle_request_timestamp(sender: int, data: PackedByteArray) -> void:
_logger.debug("Requested initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
_set_timestamp.rpc_id(multiplayer.get_remote_sender_id(), _clock.get_time())
_cmd_set_time.send(var_to_bytes(_clock.get_time()), sender)
func _handle_set_timestamp(sender: int, data: PackedByteArray) -> void:
var timestamp := bytes_to_var(data) as float
@rpc("any_peer", "call_remote", "reliable")
func _set_timestamp(timestamp: float) -> void:
_logger.debug("Received initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
_clock.set_time(timestamp)
_loop()
+44 -35
View File
@@ -19,7 +19,7 @@ var tickrate: int:
## Whether to sync the network ticks to physics updates.
##
## When set to true, tickrate will be the same as the physics ticks per second,
## When set to true, tickrate will be the same as the physics ticks per second,
## and the network tick loop will be run inside the physics update process.
##
## [i]read-only[/i], you can change this in the project settings
@@ -33,7 +33,7 @@ var sync_to_physics: bool:
##
## If the game itself runs slower than the configured tickrate, multiple ticks
## will be run in a single go. However, to avoid an endless feedback loop of
## running too many ticks in a frame, which makes the game even slower, which
## running too many ticks in a frame, which makes the game even slower, which
## results in even more ticks and so on, this setting is an upper limit on how
## many ticks can be simulated in a single go.
##
@@ -51,7 +51,7 @@ var max_ticks_per_frame: int:
##
## Use this value in cases where timestamps need to be shared with the server.
##
## [i]Note:[/i] Time is continuously synced with the server. If the difference
## [i]Note:[/i] Time is continuously synced with the server. If the difference
## between local and server time is above a certain threshold, this value will
## be adjusted.
##
@@ -72,7 +72,7 @@ var time: float:
##
## Use this value in cases where timestamps need to be shared with the server.
##
## [i]Note:[/i] Time is continuously synced with the server. If the difference
## [i]Note:[/i] Time is continuously synced with the server. If the difference
## between local and server time is above a certain threshold, this value will
## be adjusted.
##
@@ -88,7 +88,7 @@ var tick: int:
## Threshold before recalibrating [member tick] and [member time].
##
## Time is continuously synced to the server. In case the time difference is
## Time is continuously synced to the server. In case the time difference is
## excessive between local and the server, both [code]tick[/code] and
## [code]time[/code] will be reset to the estimated server values.
## [br][br]
@@ -119,7 +119,7 @@ var stall_threshold: float:
## Current network time in ticks on the server.
##
## This is value is only an estimate, and is regularly updated. This means that
## This is value is only an estimate, and is regularly updated. This means that
## this value can and probably will change depending on network conditions.
## [br][br]
## [i]read-only[/i]
@@ -133,7 +133,7 @@ var remote_tick: int:
## Current network time in seconds on the server.
##
## This is value is only an estimate, and is regularly updated. This means that
## This is value is only an estimate, and is regularly updated. This means that
## this value can and probably will change depending on network conditions.
## [br][br]
## [i]read-only[/i]
@@ -147,8 +147,8 @@ var remote_time: float:
## Estimated roundtrip time to server.
##
## This value is updated regularly, during server time sync. Latency can be
## estimated as half of the roundtrip time. Returns the same as [member
## This value is updated regularly, during server time sync. Latency can be
## estimated as half of the roundtrip time. Returns the same as [member
## _NetworkTimeSynchronizer.rtt].
## [br][br]
## Will always be 0 on servers.
@@ -163,7 +163,7 @@ var remote_rtt: float:
## Current network time in ticks.
##
## On clients, this value is synced to the server [i]only once[/i] when joining
## the game. After that, it will increase monotonically, incrementing every
## the game. After that, it will increase monotonically, incrementing every
## single tick.
## [br][br]
## When hosting, this value is simply the number of ticks since game start.
@@ -183,7 +183,7 @@ var local_tick: int:
## Current network time in seconds.
##
## On clients, this value is synced to the server [i]only once[/i] when joining
## the game. After that, it will increase monotonically, incrementing every
## the game. After that, it will increase monotonically, incrementing every
## single tick.
## [br][br]
## When hosting, this value is simply the seconds elapsed since game start.
@@ -199,7 +199,7 @@ var local_time: float:
return time
set(v):
push_error("Trying to set read-only variable local_time")
## Amount of time a single tick takes, in seconds.
##
@@ -231,13 +231,13 @@ var tick_factor: float:
## Multiplier to get from physics process speeds to tick speeds.
##
## Some methods, like CharacterBody's move_and_slide take velocity in units/sec
## and figure out the time delta on their own. However, they are not aware of
## and figure out the time delta on their own. However, they are not aware of
## netfox's time, so motion is all wrong in a network tick. For example, the
## network ticks run at 30 fps, while the game is running at 60fps, thus
## network ticks run at 30 fps, while the game is running at 60fps, thus
## move_and_slide will also assume that it's running on 60fps, resulting in
## slower than expected movement.
##
## To circument this, you can multiply any velocities with this variable, and
## To circument this, you can multiply any velocities with this variable, and
## get the desired speed. Don't forget to then divide by this value if it's a
## persistent variable ( e.g. CharacterBody's velocity ).
##
@@ -256,9 +256,9 @@ var physics_factor: float:
## The maximum clock stretch factor allowed.
##
## For more context on clock stretch, see [member clock_stretch_factor]. The
## minimum allowed clock stretch factor is derived as 1.0 / clock_stretch_max.
## Setting this to larger values will allow for quicker clock adjustment at the
## For more context on clock stretch, see [member clock_stretch_factor]. The
## minimum allowed clock stretch factor is derived as 1.0 / clock_stretch_max.
## Setting this to larger values will allow for quicker clock adjustment at the
## cost of bigger deviations in game speed.
## [br][br]
## Make sure to adjust this value based on the game's needs.
@@ -286,7 +286,7 @@ var suppress_offline_peer_warning: bool:
## to catch up. When the remote clock is ahead of the simulation clock, the game
## will run slightly faster to catch up with the remote clock.
## [br][br]
## This value indicates the current clock speed multiplier. Values over 1.0
## This value indicates the current clock speed multiplier. Values over 1.0
## indicate speeding up, under 1.0 indicate slowing down.
## [br][br]
## See [member clock_stretch_max] for clock stretch bounds.[br]
@@ -300,7 +300,7 @@ var clock_stretch_factor: float:
## The current estimated offset between the reference clock and the simulation
## clock.
##
##
## Positive values mean the simulation clock is behind, and needs to run
## slightly faster to catch up. Negative values mean the simulation clock is
## ahead, and needs to slow down slightly.
@@ -316,7 +316,7 @@ var clock_offset: float:
## The current estimated offset between the reference clock and the remote
## clock.
##
## Positive values mean the reference clock is behind the remote clock.
## Positive values mean the reference clock is behind the remote clock.
## Negative values mean the reference clock is ahead of the remote clock.
## [br][br]
## Returns the same as [member _NetworkTimeSynchronizer.remote_offset].
@@ -426,21 +426,21 @@ func start() -> int:
# Reset state
_tick = 0
_initial_sync_done = false
# Host is always synced, as their time is considered ground truth
_synced_peers[1] = true
# Start sync
NetworkTimeSynchronizer.start()
_state = _STATE_SYNCING
if not multiplayer.is_server():
await NetworkTimeSynchronizer.on_initial_sync
_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time())
_initial_sync_done = true
_state = _STATE_ACTIVE
_submit_sync_success.rpc()
else:
_state = _STATE_ACTIVE
@@ -462,13 +462,17 @@ func start() -> int:
## Stop NetworkTime.
##
## This will stop the time sync in the background, and no more ticks will be
## This will stop the time sync in the background, and no more ticks will be
## emitted until the next start.
func stop() -> void:
NetworkTimeSynchronizer.stop()
_tickrate_handshake.stop()
# Reset state
_state = _STATE_INACTIVE
_synced_peers.clear()
_tick = 0
_initial_sync_done = false
if multiplayer.peer_disconnected.is_connected(_handle_peer_disconnect):
multiplayer.peer_disconnected.disconnect(_handle_peer_disconnect)
@@ -508,7 +512,7 @@ func _ready() -> void:
_tickrate_handshake = NetworkTickrateHandshake.new()
add_child(_tickrate_handshake)
# Proxy tickrate mismatch event
_tickrate_handshake.on_tickrate_mismatch.connect(func(peer, tickrate):
on_tickrate_mismatch.emit(peer, tickrate)
@@ -524,10 +528,10 @@ func _loop() -> void:
# Adjust local clock
_clock.step(_clock_stretch_factor)
var clock_diff := NetworkTimeSynchronizer.get_time() - _clock.get_time()
# Ignore diffs under 1ms
clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.)
var clock_stretch_min := 1. / clock_stretch_max
# var clock_stretch_f = (1. + clock_diff / (1. * ticktime)) / 2.
var clock_stretch_f := inverse_lerp(-ticktime, +ticktime, clock_diff)
@@ -535,7 +539,7 @@ func _loop() -> void:
var previous_stretch_factor := _clock_stretch_factor
_clock_stretch_factor = lerpf(clock_stretch_min, clock_stretch_max, clock_stretch_f)
# Detect editor pause
var clock_step := _clock.get_time() - _last_process_time
var clock_step_raw := clock_step / previous_stretch_factor
@@ -550,7 +554,7 @@ func _loop() -> void:
_was_paused = false
_next_tick_time += clock_step
_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time())
# Run tick loop if needed
var ticks_in_loop := 0
_last_process_time = _clock.get_time()
@@ -559,19 +563,24 @@ func _loop() -> void:
before_tick_loop.emit()
before_tick.emit(ticktime, tick)
on_tick.emit(ticktime, tick)
after_tick.emit(ticktime, tick)
NetworkHistoryServer._record_sync_state(tick + 1)
NetworkSynchronizationServer._synchronize_sync_state(tick + 1)
_tick += 1
ticks_in_loop += 1
_next_tick_time += ticktime
if ticks_in_loop > 0:
after_tick_loop.emit()
NetworkHistoryServer._restore_synchronizer_state(tick)
func _process(delta: float) -> void:
_process_delta = delta
if _is_active() and not sync_to_physics:
_loop()
@@ -592,9 +601,9 @@ func _handle_peer_disconnect(peer: int) -> void:
@rpc("any_peer", "reliable", "call_local")
func _submit_sync_success() -> void:
var peer_id := multiplayer.get_remote_sender_id()
_logger.trace("Received time sync success from #%s, synced peers: %s", [peer_id, _synced_peers.keys()])
if not _synced_peers.has(peer_id):
_synced_peers[peer_id] = true
after_client_sync.emit(peer_id)
+1 -1
View File
@@ -3,5 +3,5 @@
name="netfox"
description="Shared internals for netfox addons"
author="Tamas Galffy and contributors"
version="1.35.3"
version="1.40.2"
script="netfox.gd"
@@ -1,28 +0,0 @@
extends _HistoryBuffer
class_name _PropertyHistoryBuffer
func get_snapshot(tick: int) -> _PropertySnapshot:
if _buffer.has(tick):
return _buffer[tick]
else:
return _PropertySnapshot.new()
func set_snapshot(tick: int, data) -> void:
if data is Dictionary:
var snapshot := _PropertySnapshot.from_dictionary(data)
super(tick, snapshot)
elif data is _PropertySnapshot:
super(tick, data)
else:
push_error("Data not a PropertSnapshot! %s" % [data])
func get_history(tick: int) -> _PropertySnapshot:
var snapshot = super(tick)
return snapshot if snapshot else _PropertySnapshot.new()
func trim(earliest_tick_to_keep: int = NetworkRollback.history_start) -> void:
super(earliest_tick_to_keep)
func merge(data: _PropertySnapshot, tick:int) -> void:
set_snapshot(tick, get_snapshot(tick).merge(data))
@@ -1,111 +0,0 @@
extends RefCounted
class_name _RollbackHistoryRecorder
# Provided externally by RBS
var _state_history: _PropertyHistoryBuffer
var _input_history: _PropertyHistoryBuffer
var _state_property_config: _PropertyConfig
var _input_property_config: _PropertyConfig
var _property_cache: PropertyCache
var _latest_state_tick: int
var _skipset: _Set
func configure(
p_state_history: _PropertyHistoryBuffer, p_input_history: _PropertyHistoryBuffer,
p_state_property_config: _PropertyConfig, p_input_property_config: _PropertyConfig,
p_property_cache: PropertyCache,
p_skipset: _Set
) -> void:
_state_history = p_state_history
_input_history = p_input_history
_state_property_config = p_state_property_config
_input_property_config = p_input_property_config
_property_cache = p_property_cache
_skipset = p_skipset
func set_latest_state_tick(p_latest_state_tick: int) -> void:
_latest_state_tick = p_latest_state_tick
func apply_state(tick: int) -> void:
# Apply state for tick
var state = _state_history.get_history(tick)
state.apply(_property_cache)
func apply_display_state() -> void:
apply_state(NetworkRollback.display_tick)
func apply_tick(tick: int) -> void:
var state := _state_history.get_history(tick)
var input := _input_history.get_history(tick)
state.apply(_property_cache)
input.apply(_property_cache)
func trim_history() -> void:
# Trim history
_state_history.trim()
_input_history.trim()
func record_input(tick: int) -> void:
# Record input
if not _get_recorded_input_props().is_empty():
var input = _PropertySnapshot.extract(_get_recorded_input_props())
var input_tick: int = tick + NetworkRollback.input_delay
_input_history.set_snapshot(input_tick, input)
func record_state(tick: int) -> void:
# Record state for specified tick ( current + 1 )
# Check if any of the managed nodes were mutated
var is_mutated := _get_recorded_state_props().any(func(pe):
return NetworkRollback.is_mutated(pe.node, tick - 1))
var record_state := _PropertySnapshot.extract(_get_state_props_to_record(tick))
if record_state.size():
var merge_state := _state_history.get_history(tick - 1)
_state_history.set_snapshot(tick, merge_state.merge(record_state))
func _should_record_tick(tick: int) -> bool:
if _get_recorded_state_props().is_empty():
# Don't record tick if there's no props to record
return false
if _get_recorded_state_props().any(func(pe):
return NetworkRollback.is_mutated(pe.node, tick - 1)):
# If there's any node that was mutated, there's something to record
return true
# Otherwise, record only if we don't have authoritative state for the tick
return tick > _latest_state_tick
func _get_state_props_to_record(tick: int) -> Array[PropertyEntry]:
if not _should_record_tick(tick):
return []
if _skipset.is_empty():
return _get_recorded_state_props()
return _get_recorded_state_props().filter(func(pe): return _should_record_property(pe, tick))
func _should_record_property(property_entry: PropertyEntry, tick: int) -> bool:
if NetworkRollback.is_mutated(property_entry.node, tick - 1):
return true
if _skipset.has(property_entry.node):
return false
return true
# =============================================================================
# Shared utils, extract later
func _get_recorded_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_properties()
func _get_owned_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_owned_properties()
func _get_recorded_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
func _get_owned_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
@@ -1 +0,0 @@
uid://bn6fsqxbfhihk
@@ -1,282 +0,0 @@
extends Node
class_name _RollbackHistoryTransmitter
var root: Node
var enable_input_broadcast: bool = true
var full_state_interval: int
var diff_ack_interval: int
# Provided externally by RBS
var _state_history: _PropertyHistoryBuffer
var _input_history: _PropertyHistoryBuffer
var _visibility_filter: PeerVisibilityFilter
var _state_property_config: _PropertyConfig
var _input_property_config: _PropertyConfig
var _property_cache: PropertyCache
var _skipset: _Set
# Collaborators
var _input_encoder: _RedundantHistoryEncoder
var _full_state_encoder: _SnapshotHistoryEncoder
var _diff_state_encoder: _DiffHistoryEncoder
# State
var _ackd_state: Dictionary = {}
var _next_full_state_tick: int
var _next_diff_ack_tick: int
var _earliest_input_tick: int
var _latest_state_tick: int
var _is_predicted_tick: bool
var _is_initialized: bool
# Signals
signal _on_transmit_state(state: Dictionary, tick: int)
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("RollbackHistoryTransmitter")
func get_earliest_input_tick() -> int:
return _earliest_input_tick
func get_latest_state_tick() -> int:
return _latest_state_tick
func set_predicted_tick(p_is_predicted_tick) -> void:
_is_predicted_tick = p_is_predicted_tick
func sync_settings(p_root: Node, p_enable_input_broadcast: bool, p_full_state_interval: int, p_diff_ack_interval: int) -> void:
root = p_root
enable_input_broadcast = p_enable_input_broadcast
full_state_interval = p_full_state_interval
diff_ack_interval = p_diff_ack_interval
func configure(
p_state_history: _PropertyHistoryBuffer, p_input_history: _PropertyHistoryBuffer,
p_state_property_config: _PropertyConfig, p_input_property_config: _PropertyConfig,
p_visibility_filter: PeerVisibilityFilter,
p_property_cache: PropertyCache,
p_skipset: _Set
) -> void:
_state_history = p_state_history
_input_history = p_input_history
_state_property_config = p_state_property_config
_input_property_config = p_input_property_config
_visibility_filter = p_visibility_filter
_property_cache = p_property_cache
_skipset = p_skipset
_input_encoder = _RedundantHistoryEncoder.new(_input_history, _property_cache)
_full_state_encoder = _SnapshotHistoryEncoder.new(_state_history, _property_cache)
_diff_state_encoder = _DiffHistoryEncoder.new(_state_history, _property_cache)
_is_initialized = true
reset()
func reset() -> void:
_ackd_state.clear()
_latest_state_tick = NetworkTime.tick - 1
_earliest_input_tick = NetworkTime.tick
_next_full_state_tick = NetworkTime.tick
_next_diff_ack_tick = NetworkTime.tick
# Scatter full state sends, so not all nodes send at the same tick
if is_inside_tree():
_next_full_state_tick += hash(root.get_path()) % maxi(1, full_state_interval)
_next_diff_ack_tick += hash(root.get_path()) % maxi(1, diff_ack_interval)
else:
_next_full_state_tick += hash(root.name) % maxi(1, full_state_interval)
_next_diff_ack_tick += hash(root.name) % maxi(1, diff_ack_interval)
_diff_state_encoder.add_properties(_state_property_config.get_properties())
_full_state_encoder.set_properties(_get_owned_state_props())
_input_encoder.set_properties(_get_owned_input_props())
func conclude_tick_loop() -> void:
_earliest_input_tick = NetworkTime.tick
func transmit_input(tick: int) -> void:
if not _get_owned_input_props().is_empty():
var input_tick: int = tick + NetworkRollback.input_delay
var input_data := _input_encoder.encode(input_tick, _get_owned_input_props())
var state_owning_peer := root.get_multiplayer_authority()
NetworkRollback.register_input_submission(root, tick)
if enable_input_broadcast:
for peer in _visibility_filter.get_rpc_target_peers():
_submit_input.rpc_id(peer, input_tick, input_data)
elif state_owning_peer != multiplayer.get_unique_id():
_submit_input.rpc_id(state_owning_peer, input_tick, input_data)
func transmit_state(tick: int) -> void:
if _get_owned_state_props().is_empty():
# We don't own state, don't transmit anything
return
if _is_predicted_tick and not _input_property_config.get_properties().is_empty():
# Don't transmit anything if we're predicting
# EXCEPT when we're running inputless
return
# Include properties we own
var full_state := _PropertySnapshot.new()
for property in _get_owned_state_props():
if _should_broadcast(property, tick):
full_state.set_value(property.to_string(), property.get_value())
_on_transmit_state.emit(full_state, tick)
# No properties to send?
if full_state.is_empty():
return
_latest_state_tick = max(_latest_state_tick, tick)
var is_sending_diffs := NetworkRollback.enable_diff_states
var is_full_state_tick := not is_sending_diffs or (full_state_interval > 0 and tick > _next_full_state_tick)
if is_full_state_tick:
# Broadcast new full state
for peer in _visibility_filter.get_rpc_target_peers():
_send_full_state(tick, peer)
# Adjust next full state if sending diffs
if is_sending_diffs:
_next_full_state_tick = tick + full_state_interval
else:
# Send diffs to each peer
for peer in _visibility_filter.get_visible_peers():
var reference_tick := _ackd_state.get(peer, -1) as int
if reference_tick < 0 or not _state_history.has(reference_tick):
# Peer hasn't ack'd any tick, or we don't have the ack'd tick
# Send full state
_send_full_state(tick, peer)
continue
# Prepare diff
var diff_state_data := _diff_state_encoder.encode(tick, reference_tick, _get_owned_state_props())
if _diff_state_encoder.get_full_snapshot().size() == _diff_state_encoder.get_encoded_snapshot().size():
# State is completely different, send full state
_send_full_state(tick, peer)
else:
# Send only diff
_submit_diff_state.rpc_id(peer, diff_state_data, tick, reference_tick)
# Push metrics
NetworkPerformance.push_full_state(_diff_state_encoder.get_full_snapshot())
NetworkPerformance.push_sent_state(_diff_state_encoder.get_encoded_snapshot())
func _should_broadcast(property: PropertyEntry, tick: int) -> bool:
# Only broadcast if we've simulated the node
# NOTE: _can_simulate checks mutations, but to override _skipset
# we check first
if NetworkRollback.is_mutated(property.node, tick - 1):
return true
if _skipset.has(property.node):
return false
if NetworkRollback.is_rollback_aware(property.node):
return NetworkRollback.is_simulated(property.node)
# Node is not rollback-aware, broadcast updates only if we own it
return property.node.is_multiplayer_authority()
func _send_full_state(tick: int, peer: int = 0) -> void:
var full_state_snapshot := _state_history.get_snapshot(tick).as_dictionary()
var full_state_data := _full_state_encoder.encode(tick, _get_owned_state_props())
_submit_full_state.rpc_id(peer, full_state_data, tick)
if peer <= 0:
NetworkPerformance.push_full_state_broadcast(full_state_snapshot)
NetworkPerformance.push_sent_state_broadcast(full_state_snapshot)
else:
NetworkPerformance.push_full_state(full_state_snapshot)
NetworkPerformance.push_sent_state(full_state_snapshot)
func _notification(what):
if what == NOTIFICATION_PREDELETE:
NetworkRollback.free_input_submission_data_for(root)
@rpc("any_peer", "unreliable", "call_remote")
func _submit_input(tick: int, data: Array) -> void:
if not _is_initialized:
# Settings not processed yet
return
var sender := multiplayer.get_remote_sender_id()
var snapshots := _input_encoder.decode(data, _input_property_config.get_properties_owned_by(sender))
var earliest_received_input = _input_encoder.apply(tick, snapshots, sender)
if earliest_received_input >= 0:
_earliest_input_tick = mini(_earliest_input_tick, earliest_received_input)
NetworkRollback.register_input_submission(root, tick)
# `serialized_state` is a serialized _PropertySnapshot
@rpc("any_peer", "unreliable_ordered", "call_remote")
func _submit_full_state(data: Array, tick: int) -> void:
if not _is_initialized:
# Settings not processed yet
return
var sender := multiplayer.get_remote_sender_id()
var snapshot := _full_state_encoder.decode(data, _state_property_config.get_properties_owned_by(sender))
if not _full_state_encoder.apply(tick, snapshot, sender):
# Invalid data
return
_latest_state_tick = tick
if NetworkRollback.enable_diff_states:
_ack_full_state.rpc_id(sender, tick)
# State is a serialized _PropertySnapshot (Dictionary[String, Variant])
@rpc("any_peer", "unreliable_ordered", "call_remote")
func _submit_diff_state(data: PackedByteArray, tick: int, reference_tick: int) -> void:
if not _is_initialized:
# Settings not processed yet
return
var sender = multiplayer.get_remote_sender_id()
var diff_snapshot := _diff_state_encoder.decode(data, _state_property_config.get_properties_owned_by(sender))
if not _diff_state_encoder.apply(tick, diff_snapshot, reference_tick, sender):
# Invalid data
return
_latest_state_tick = tick
if NetworkRollback.enable_diff_states:
if diff_ack_interval > 0 and tick > _next_diff_ack_tick:
_ack_diff_state.rpc_id(sender, tick)
_next_diff_ack_tick = tick + diff_ack_interval
@rpc("any_peer", "reliable", "call_remote")
func _ack_full_state(tick: int) -> void:
var sender_id := multiplayer.get_remote_sender_id()
_ackd_state[sender_id] = tick
_logger.trace("Peer %d ack'd full state for tick %d", [sender_id, tick])
@rpc("any_peer", "unreliable_ordered", "call_remote")
func _ack_diff_state(tick: int) -> void:
var sender_id := multiplayer.get_remote_sender_id()
_ackd_state[sender_id] = tick
_logger.trace("Peer %d ack'd diff state for tick %d", [sender_id, tick])
# =============================================================================
# Shared utils, extract later
func _get_recorded_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_properties()
func _get_owned_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_owned_properties()
func _get_recorded_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
func _get_owned_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
@@ -1 +0,0 @@
uid://ofadsxmvd3e3
@@ -0,0 +1 @@
uid://bc61m7m28c1k7
+67 -17
View File
@@ -85,7 +85,6 @@ var display_tick: int:
## with input latency higher than network latency.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var input_delay: int:
get:
return _input_delay
@@ -100,7 +99,6 @@ var input_delay: int:
## in transmission, the next (n-1) packets will contain the data for it.
## [br][br]
## [i]read-only[/i], you can change this in the project settings
var input_redundancy: int:
get:
return max(1, _input_redundancy)
@@ -168,7 +166,9 @@ var _rollback_stage: String = ""
var _is_rollback: bool = false
var _simulated_nodes: _Set = _Set.new()
var _mutated_nodes: Dictionary = {}
var _input_submissions: Dictionary = {}
var _earliest_input := -1
var _earliest_state := -1
const _STAGE_BEFORE := "B"
const _STAGE_PREPARE := "P"
@@ -268,33 +268,44 @@ func is_just_mutated(target: Object, p_tick: int = tick) -> bool:
return false
## Register that a node has submitted its input for a specific tick
func register_input_submission(root_node: Node, tick: int) -> void:
if not _input_submissions.has(root_node):
_input_submissions[root_node] = tick
else:
_input_submissions[root_node] = maxi(_input_submissions[root_node], tick)
## @deprecated
func register_rollback_input_submission(_node: Node, _tick: int) -> void:
pass
## Get the latest input tick submitted by a specific root node
## Get the latest input tick submitted for a specific node
## [br][br]
## Returns [code]-1[/code] if no input was submitted for the node, ever.
func get_latest_input_tick(root_node: Node) -> int:
if _input_submissions.has(root_node):
return _input_submissions[root_node]
return -1
func get_latest_input_tick(node: Node) -> int:
var input_nodes := RollbackSimulationServer._get_inputs_of(node)
var reference_tick := NetworkTime.tick
return NetworkHistoryServer.get_latest_input_for(input_nodes, reference_tick)
## Check if a node has submitted input for a specific tick (or later)
func has_input_for_tick(root_node: Node, tick: int) -> bool:
return _input_submissions.has(root_node) and _input_submissions[root_node] >= tick
func has_input_for_tick(node: Node, tick: int) -> bool:
var latest_input := get_latest_input_tick(node)
return latest_input != -1 and latest_input >= tick
## Free all input submission data for a node
## [br][br]
## Use this once the node is freed.
func free_input_submission_data_for(root_node: Node) -> void:
_input_submissions.erase(root_node)
## @deprecated
func free_input_submission_data_for(_node: Node) -> void:
pass
func _get_rollback_method(object: Object) -> Callable:
return object._rollback_tick
func _ready():
NetfoxLogger.register_tag(_get_rollback_tag)
NetworkTime.after_tick_loop.connect(_rollback)
NetworkTime.after_tick.connect(func(_dt, tick):
NetworkHistoryServer._record_rollback_input(tick + input_delay)
NetworkSynchronizationServer._synchronize_input(tick + input_delay)
)
NetworkSynchronizationServer._on_input.connect(_handle_input)
NetworkSynchronizationServer._on_state.connect(_handle_state)
func _exit_tree():
NetfoxLogger.free_tag(_get_rollback_tag)
@@ -313,6 +324,17 @@ func _rollback() -> void:
_resim_from = NetworkTime.tick
before_loop.emit()
# Figure out where to start rollback from
var range_source = "notif"
if _earliest_input >= 0 and _earliest_input <= _resim_from:
range_source = "earliest input"
_resim_from = _earliest_input
if _earliest_state >= 0 and _earliest_state <= _resim_from:
range_source = "latest state"
_resim_from = _earliest_state
_resim_from = mini(_resim_from, NetworkTime.tick - 1)
_logger.trace("Simulating range @%d>@%d using %s", [_resim_from, NetworkTime.tick, range_source])
# Only set _is_rollback *after* emitting before_loop
_is_rollback = true
_rollback_stage = _STAGE_BEFORE
@@ -331,6 +353,9 @@ func _rollback() -> void:
)
from = NetworkTime.tick - history_limit
_earliest_input = -1
_earliest_state = -1
# for tick in from .. to:
_rollback_from = from
_rollback_to = to
@@ -343,6 +368,8 @@ func _rollback() -> void:
# Restore input and state for tick
_rollback_stage = _STAGE_PREPARE
on_prepare_tick.emit(tick)
NetworkHistoryServer._restore_rollback_input(tick)
NetworkHistoryServer._restore_rollback_state(tick)
after_prepare_tick.emit(tick)
# Simulate rollback tick
@@ -353,20 +380,43 @@ func _rollback() -> void:
# If not: Latest input >= tick >= Earliest input
_rollback_stage = _STAGE_SIMULATE
on_process_tick.emit(tick)
RollbackSimulationServer.simulate(NetworkTime.ticktime, tick)
after_process_tick.emit(tick)
# Record state for tick + 1
_rollback_stage = _STAGE_RECORD
on_record_tick.emit(tick + 1)
NetworkHistoryServer._record_rollback_state(tick + 1)
NetworkSynchronizationServer._synchronize_state(tick + 1)
# Restore display state
_rollback_stage = _STAGE_AFTER
after_loop.emit()
NetworkHistoryServer._restore_rollback_state(display_tick)
RollbackSimulationServer._trim_ticks_simulated(history_start)
# Cleanup
_mutated_nodes.clear()
_is_rollback = false
func _handle_input(snapshot: _Snapshot):
if snapshot.is_empty():
return
if _earliest_input < 0 or snapshot.tick < _earliest_input:
_logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, snapshot.tick])
_earliest_input = snapshot.tick
else:
_logger.trace("Ingested input @%d, earliest @%d->@%d", [snapshot.tick, _earliest_input, _earliest_input])
func _handle_state(snapshot: _Snapshot):
if snapshot.is_empty():
return
if _earliest_state < 0 or snapshot.tick < _earliest_state:
_logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _earliest_state, snapshot.tick])
_earliest_state = snapshot.tick
else:
_logger.trace("Ingested state @%d, latest @%d->@%d", [snapshot.tick, _earliest_state, _earliest_state])
# Insight 1:
# state(x) = simulate(state(x - 1), input(x - 1))
# state(x + 1) = simulate(state(x), input(x))
@@ -0,0 +1,121 @@
@tool
extends Node
class_name PredictiveSynchronizer
## Similar to [RollbackSynchronizer], this class manages local variables in a
## rollback context for predictive simulation without networking.
##
## This is a simplified version that focuses on local state management.
## [br][br]
## Like [RollbackSynchronizer], it automatically discovers nodes
## with a [code]_rollback_tick(delta: float, tick: int)[/code]
## method and calls them during the prediction phase.
## The root node for resolving node paths in properties. Defaults to the parent
## node.
@export var root: Node = get_parent()
@export_group("State")
## Properties that define the game state.
## [br][br]
## State properties are recorded for each tick and restored during rollback.
## State is restored before every rollback tick, and recorded after simulating
## the tick.
@export var state_properties: Array[String]
var _state_properties := _PropertyPool.new()
var _sim_nodes: Array[Node] = []
var _properties_dirty: bool = false
## Process settings.
##
## Call this after any change to configuration.
func process_settings() -> void:
# Deregister all nodes we've registered previously
for subject in _state_properties.get_subjects():
NetworkHistoryServer.deregister(subject)
for node in _sim_nodes:
RollbackSimulationServer.deregister_node(node)
# Gather all prediction-aware nodes to call during prediction ticks
_sim_nodes = root.find_children("*")
_sim_nodes.push_front(root)
_sim_nodes = _sim_nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it))
_sim_nodes.erase(self)
# Keep history of state properties
_state_properties.set_from_paths(root, state_properties)
for subject in _state_properties.get_subjects():
for property in _state_properties.get_properties_of(subject):
NetworkHistoryServer.register_rollback_state(subject, property)
# Simulated notes to participate in rollback
for node in _sim_nodes:
RollbackSimulationServer.register(NetworkRollback._get_rollback_method(node))
func _ready() -> void:
if Engine.is_editor_hint():
return
if not NetworkTime.is_initial_sync_done():
# Wait for time sync to complete
await NetworkTime.after_sync
process_settings.call_deferred()
func _enter_tree() -> void:
if Engine.is_editor_hint():
return
if not NetworkTime.is_initial_sync_done():
# Wait for time sync to complete
await NetworkTime.after_sync
process_settings.call_deferred()
func _reprocess_settings() -> void:
if not _properties_dirty or Engine.is_editor_hint():
return
_properties_dirty = false
process_settings()
## Add a state property.
## [br][br]
## Settings will be automatically updated. The [param node] may be a string or
## [NodePath] pointing to a node, or an actual [Node] instance. If the given
## property is already tracked, this method does nothing.
func add_state(node: Variant, property: String):
var property_path := PropertyEntry.make_path(root, node, property)
if not property_path or state_properties.has(property_path):
return
state_properties.push_back(property_path)
_properties_dirty = true
_reprocess_settings.call_deferred()
func _notification(what: int) -> void:
if what == NOTIFICATION_EDITOR_PRE_SAVE:
update_configuration_warnings()
if what == NOTIFICATION_PREDELETE:
for node in _sim_nodes:
RollbackSimulationServer.deregister_node(node)
for subject in _state_properties.get_subjects():
NetworkHistoryServer.deregister(subject)
func _get_configuration_warnings() -> PackedStringArray:
if not root:
root = get_parent()
# Explore state properties
if not root:
return ["No valid root node found!"]
var result := PackedStringArray()
result.append_array(_NetfoxEditorUtils.gather_properties(root, "_get_rollback_state_properties",
func(node, prop):
add_state(node, prop)
))
return result
@@ -0,0 +1 @@
uid://ct51w7rsjcxvw
@@ -1,34 +0,0 @@
extends RefCounted
class_name RollbackFreshnessStore
## This class tracks nodes and whether they have processed any given tick during
## a rollback.
# TODO: _Set
var _data: Dictionary = {}
func is_fresh(node: Node, tick: int) -> bool:
if not _data.has(tick):
return true
if not _data[tick].has(node):
return true
return false
func notify_processed(node: Node, tick: int) -> void:
if not _data.has(tick):
_data[tick] = {}
_data[tick][node] = true
func trim() -> void:
while not _data.is_empty():
var earliest_tick := _data.keys().min()
if earliest_tick < NetworkRollback.history_start:
_data.erase(earliest_tick)
else:
break
func clear():
_data.clear()
+120 -243
View File
@@ -32,23 +32,11 @@ class_name RollbackSynchronizer
## for every tick.
## [br][br]
## Only considered if [member _NetworkRollback.enable_diff_states] is true.
## @deprecated: This can now be configured in the project settings.
@export_range(0, 128, 1, "or_greater")
var full_state_interval: int = 24
## Ticks to wait between unreliably acknowledging diff states.
## [br][br]
## This can reduce the amount of properties sent in diff states, due to clients
## more often acknowledging received states. To avoid introducing hickups, these
## are sent unreliably.
## [br][br]
## If set to 0, diff states will never be acknowledged. If set to 1, all diff
## states will be acknowledged. If set higher, ack's will be sent regularly, but
## not for every diff state.
## [br][br]
## If enabled, it's worth to tune this setting until network traffic is actually
## reduced.
## [br][br]
## Only considered if [member _NetworkRollback.enable_diff_states] is true.
## @deprecated: This is no longer used.
@export_range(0, 128, 1, "or_greater")
var diff_ack_interval: int = 0
@@ -62,74 +50,95 @@ var diff_ack_interval: int = 0
## This will broadcast input to all peers, turning this off will limit to
## sending it to the server only. Turning this off is recommended to save
## bandwidth and reduce cheating risks.
## @deprecated: This can now be configured in the project settings.
@export var enable_input_broadcast: bool = true
# Make sure this exists from the get-go, just not in the scene tree
## Decides which peers will receive updates
var visibility_filter := PeerVisibilityFilter.new()
var _state_property_config: _PropertyConfig = _PropertyConfig.new()
var _input_property_config: _PropertyConfig = _PropertyConfig.new()
var _nodes: Array[Node] = []
var _simset: _Set = _Set.new()
var _skipset: _Set = _Set.new()
var _state_properties := _PropertyPool.new()
var _input_properties := _PropertyPool.new()
var _sim_nodes := [] as Array[Node]
var _schema_nodes := _Set.new()
var _properties_dirty: bool = false
var _property_cache := PropertyCache.new(root)
var _freshness_store := RollbackFreshnessStore.new()
var _states := _PropertyHistoryBuffer.new()
var _inputs := _PropertyHistoryBuffer.new()
var _last_simulated_tick: int
var _has_input: bool
var _input_tick: int
var _is_predicted_tick: bool
static var _logger: NetfoxLogger = NetfoxLogger._for_netfox("RollbackSynchronizer")
# Composition
var _history_transmitter: _RollbackHistoryTransmitter
var _history_recorder: _RollbackHistoryRecorder
## Process settings.
##
## [br][br]
## Call this after any change to configuration. Updates based on authority too
## ( calls process_authority ).
func process_settings() -> void:
# Deregister simulated, state and input nodes
for node in _sim_nodes + _state_properties.get_subjects() + _input_properties.get_subjects():
RollbackSimulationServer.deregister_node(node)
_sim_nodes.clear()
# Clear
_property_cache.root = root
_property_cache.clear()
_freshness_store.clear()
_nodes.clear()
_states.clear()
_inputs.clear()
process_authority()
# Gather all rollback-aware nodes to simulate during rollbacks
_nodes = root.find_children("*")
_nodes.push_front(root)
_nodes = _nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it))
_nodes.erase(self)
var nodes := root.find_children("*") as Array[Node]
nodes.push_front(root)
nodes = nodes.filter(func(it): return NetworkRollback.is_rollback_aware(it))
nodes.erase(self)
_history_transmitter.sync_settings(root, enable_input_broadcast, full_state_interval, diff_ack_interval)
_history_transmitter.configure(_states, _inputs, _state_property_config, _input_property_config, visibility_filter, _property_cache, _skipset)
_history_recorder.configure(_states, _inputs, _state_property_config, _input_property_config, _property_cache, _skipset)
# Register simulation callbacks
for node in nodes:
RollbackSimulationServer.register(NetworkRollback._get_rollback_method(node))
_sim_nodes.append(node)
# Both simulated and state nodes depend on all inputs
# TODO(#564): Write tests for setups where a node is synchronized but not simulated
for node in nodes + _state_properties.get_subjects():
for input_node in _input_properties.get_subjects():
RollbackSimulationServer.register_rollback_input_for(node, input_node)
# Register identifiers
for node in _state_properties.get_subjects() + _input_properties.get_subjects():
NetworkIdentityServer.register_node(node)
# Register visibility filter
for node in _state_properties.get_subjects():
NetworkSynchronizationServer.register_visibility_filter(node, visibility_filter)
## Process settings based on authority.
##
## [br][br]
## Call this whenever the authority of any of the nodes managed by
## RollbackSynchronizer changes. Make sure to do this at the same time on all
## peers.
func process_authority():
_state_property_config.local_peer_id = multiplayer.get_unique_id()
_input_property_config.local_peer_id = multiplayer.get_unique_id()
# Deregister all recorded properties
for node in _state_properties.get_subjects():
for property in _state_properties.get_properties_of(node):
NetworkHistoryServer.deregister_rollback_state(node, property)
NetworkSynchronizationServer.deregister_rollback_state(node, property)
_state_property_config.set_properties_from_paths(state_properties, _property_cache)
_input_property_config.set_properties_from_paths(input_properties, _property_cache)
for node in _input_properties.get_subjects():
for property in _input_properties.get_properties_of(node):
NetworkHistoryServer.deregister_rollback_input(node, property)
NetworkSynchronizationServer.deregister_rollback_input(node, property)
# Process authority
_state_properties.set_from_paths(root, state_properties)
_input_properties.set_from_paths(root, input_properties)
# Register new recorded properties
for node in _state_properties.get_subjects():
for property in _state_properties.get_properties_of(node):
NetworkHistoryServer.register_rollback_state(node, property)
NetworkSynchronizationServer.register_rollback_state(node, property)
for node in _input_properties.get_subjects():
for property in _input_properties.get_properties_of(node):
NetworkHistoryServer.register_rollback_input(node, property)
NetworkSynchronizationServer.register_rollback_input(node, property)
## Add a state property.
## [br][br]
@@ -159,42 +168,77 @@ func add_input(node: Variant, property: String) -> void:
_properties_dirty = true
_reprocess_settings.call_deferred()
## Set the schema for transmitting properties over the network.
## [br][br]
## The [param schema] must be a dictionary, with the keys being property path
## strings, and the values are the associated [NetworkSchemaSerializer] objects.
## Properties are interpreted relative to the [member root] node. The schema can
## contain both state and input properties. Properties not specified in the
## schema will use a generic fallback serializer. By using the right serializer
## for the right property, bandwidth usage can be lowered.
## [br][br]
## See [NetworkSchemas] for many common serializers.
## [br][br]
## Example:
## [codeblock]
## rollback_synchronizer.set_schema({
## ":transform": NetworkSchemas.transform3f32(),
## ":velocity": NetworkSchemas.vec3f32(),
## "Input:movement": NetworkSchemas.vec3f32()
## })
## [/codeblock]
func set_schema(schema: Dictionary) -> void:
# Remove previous schema
for node in _schema_nodes:
NetworkSynchronizationServer.deregister_schema_for(node)
_schema_nodes.clear()
# Register new schema
for prop in schema:
var prop_entry := PropertyEntry.parse(root, prop)
var serializer := schema[prop] as NetworkSchemaSerializer
NetworkSynchronizationServer.register_schema(prop_entry.node, prop_entry.property, serializer)
_schema_nodes.add(prop_entry.node)
## Check if input is available for the current tick.
##
## [br][br]
## This input is not always current, it may be from multiple ticks ago.
## [br][br]
## Returns true if input is available.
func has_input() -> bool:
return _has_input
return get_input_age() >= 0
## Get the age of currently available input in ticks.
##
## [br][br]
## The available input may be from the current tick, or from multiple ticks ago.
## This number of tick is the input's age.
## [br][br]
## Calling this when [member has_input] is false will yield an error.
func get_input_age() -> int:
if has_input():
return NetworkRollback.tick - _input_tick
else:
_logger.error("Trying to check input age without having input!")
return -1
return NetworkHistoryServer.get_input_age_for(_input_properties.get_subjects(), NetworkRollback.tick)
## Check if the current tick is predicted.
##
## [br][br]
## A tick becomes predicted if there's no up-to-date input available. It will be
## simulated and recorded, but will not be broadcast, nor considered
## authoritative.
func is_predicting() -> bool:
return _is_predicted_tick
if RollbackSimulationServer.get_simulated_object() != null:
# An object is being simulated, check if it's predicted
return RollbackSimulationServer.is_predicting_current()
else:
# We're outside of simulation, predicting if we don't have current input
return get_input_age() != 0
## Ignore a node's prediction for the current rollback tick.
##
## [br][br]
## Call this when the input is too old to base predictions on. This call is
## ignored if [member enable_prediction] is false.
func ignore_prediction(node: Node) -> void:
if enable_prediction:
_skipset.add(node)
# Not needed, netfox records properties as non-auth if predicting
# Once the data is received from the owner, it won't be overwritten by
# predictions.
#
# This method may see some use again, otherwise it will be deprecated.
pass
## Get the tick of the last known input.
## [br][br]
@@ -208,11 +252,7 @@ func ignore_prediction(node: Node) -> void:
## [br][br]
## Returns -1 if there's no known input.
func get_last_known_input() -> int:
# If we own input, it is updated regularly, this will be the current tick
# If we don't own input, _inputs is only updated when input data is received
if not _inputs.is_empty():
return _inputs.keys().max()
return -1
return NetworkHistoryServer.get_latest_input_for(_input_properties.get_subjects(), NetworkTime.tick)
## Get the tick of the last known state.
## [br][br]
@@ -221,10 +261,7 @@ func get_last_known_input() -> int:
## data may change as new input arrives. For peers that don't own state, this
## will be the tick of the latest state received from the state owner.
func get_last_known_state() -> int:
# If we own state, this will be updated when recording and broadcasting
# state, this will be the current tick
# If we don't own state, this will be updated when state data is received
return _history_transmitter.get_latest_state_tick()
return NetworkHistoryServer.get_state_age_for(_state_properties.get_subjects(), NetworkTime.tick)
func _ready() -> void:
if Engine.is_editor_hint():
@@ -235,60 +272,17 @@ func _ready() -> void:
await NetworkTime.after_sync
process_settings.call_deferred()
func _connect_signals() -> void:
NetworkTime.before_tick.connect(_before_tick)
NetworkTime.after_tick.connect(_after_tick)
NetworkRollback.on_prepare_tick.connect(_on_prepare_tick)
NetworkRollback.on_process_tick.connect(_process_tick)
NetworkRollback.on_record_tick.connect(_on_record_tick)
NetworkRollback.before_loop.connect(_before_rollback_loop)
NetworkRollback.after_loop.connect(_after_rollback_loop)
func _disconnect_signals() -> void:
NetworkTime.before_tick.disconnect(_before_tick)
NetworkTime.after_tick.disconnect(_after_tick)
NetworkRollback.on_prepare_tick.disconnect(_on_prepare_tick)
NetworkRollback.on_process_tick.disconnect(_process_tick)
NetworkRollback.on_record_tick.disconnect(_on_record_tick)
NetworkRollback.before_loop.disconnect(_before_rollback_loop)
NetworkRollback.after_loop.disconnect(_after_rollback_loop)
func _before_tick(_dt: float, tick: int) -> void:
_history_recorder.apply_state(tick)
func _after_tick(_dt: float, tick: int) -> void:
_history_recorder.record_input(tick)
_history_transmitter.transmit_input(tick)
_history_recorder.trim_history()
_freshness_store.trim()
func _before_rollback_loop() -> void:
_notify_resim()
func _on_prepare_tick(tick: int) -> void:
_history_recorder.apply_tick(tick)
_prepare_tick_process(tick)
func _process_tick(tick: int) -> void:
_run_rollback_tick(tick)
_push_simset_metrics()
func _on_record_tick(tick: int) -> void:
_history_recorder.record_state(tick)
_history_transmitter.transmit_state(tick)
func _after_rollback_loop() -> void:
_history_recorder.apply_display_state()
_history_transmitter.conclude_tick_loop()
multiplayer.connected_to_server.connect(process_settings)
func _notification(what: int) -> void:
if what == NOTIFICATION_EDITOR_PRE_SAVE:
update_configuration_warnings()
elif what == NOTIFICATION_PREDELETE:
for node in _sim_nodes + _state_properties.get_subjects() + _input_properties.get_subjects():
RollbackSimulationServer.deregister_node(node)
NetworkSynchronizationServer.deregister(node)
NetworkIdentityServer.deregister_node(node)
NetworkHistoryServer.deregister(node)
func _get_configuration_warnings() -> PackedStringArray:
if not root:
@@ -321,131 +315,14 @@ func _enter_tree() -> void:
if not visibility_filter.get_parent():
add_child(visibility_filter)
if _history_transmitter == null:
_history_transmitter = _RollbackHistoryTransmitter.new()
add_child(_history_transmitter, true)
_history_transmitter.set_multiplayer_authority(get_multiplayer_authority())
if _history_recorder == null:
_history_recorder = _RollbackHistoryRecorder.new()
if not NetworkTime.is_initial_sync_done():
# Wait for time sync to complete
await NetworkTime.after_sync
_connect_signals.call_deferred()
process_settings.call_deferred()
func _exit_tree() -> void:
if Engine.is_editor_hint():
return
_disconnect_signals()
func _notify_resim() -> void:
if _get_owned_input_props().is_empty():
# We don't have any inputs we own, simulate from earliest we've received
NetworkRollback.notify_resimulation_start(_history_transmitter.get_earliest_input_tick())
else:
# We own inputs, simulate from latest authorative state
NetworkRollback.notify_resimulation_start(_history_transmitter.get_latest_state_tick())
func _prepare_tick_process(tick: int) -> void:
_history_recorder.set_latest_state_tick(_history_transmitter._latest_state_tick)
# Save data for input prediction
var retrieved_tick := _inputs.get_closest_tick(tick)
# These are used as input for input age ( i.e. do we even have input, and if so, how old? )
_has_input = retrieved_tick != -1
_input_tick = retrieved_tick
# Used to explicitly determine if this is a predicted tick
# ( even if we could grab *some* input )
_is_predicted_tick = _is_predicted_tick_for(null, tick)
_history_transmitter.set_predicted_tick(_is_predicted_tick)
# Reset the set of simulated and ignored nodes
_simset.clear()
_skipset.clear()
# Gather nodes that can be simulated
for node in _nodes:
if _can_simulate(node, tick):
NetworkRollback.notify_simulated(node)
func _can_simulate(node: Node, tick: int) -> bool:
if not enable_prediction and _is_predicted_tick_for(node, tick):
# Don't simulate if prediction is not allowed and tick is predicted
return false
if NetworkRollback.is_mutated(node, tick):
# Mutated nodes are always resimulated
return true
if input_properties.is_empty():
# If we're running inputless and own the node, simulate it if we haven't
if node.is_multiplayer_authority():
return tick > _last_simulated_tick
# If we're running inputless and don't own the node, only run as prediction
return enable_prediction
if node.is_multiplayer_authority():
# Simulate from earliest input
# Don't simulate frames we don't have input for
return tick >= _history_transmitter.get_earliest_input_tick()
else:
# Simulate ONLY if we have state from server
# Simulate from latest authorative state - anything the server confirmed we don't rerun
# Don't simulate frames we don't have input for
return tick >= _history_transmitter.get_latest_state_tick()
# `node` can be set to null, in case we're not simulating a specific node
func _is_predicted_tick_for(node: Node, tick: int) -> bool:
if input_properties.is_empty() and node != null:
# We're running without inputs
# It's only predicted if we don't own the node
return not node.is_multiplayer_authority()
else:
# We have input properties, it's only predicted if we don't have the input for the tick
return not _inputs.has(tick)
func _run_rollback_tick(tick: int) -> void:
# Simulate rollback tick
# Method call on rewindables
# Rollback synchronizers go through each node they manage
# If current tick is in node's range, tick
# If authority: Latest input >= tick >= Latest state
# If not: Latest input >= tick >= Earliest input
for node in _nodes:
if not NetworkRollback.is_simulated(node):
continue
var is_fresh := _freshness_store.is_fresh(node, tick)
_is_predicted_tick = _is_predicted_tick_for(node, tick)
NetworkRollback.process_rollback(node, NetworkTime.ticktime, tick, is_fresh)
if _skipset.has(node):
continue
_freshness_store.notify_processed(node, tick)
_simset.add(node)
func _push_simset_metrics():
# Push metrics
NetworkPerformance.push_rollback_nodes_simulated(_simset.size())
func _reprocess_settings() -> void:
if not _properties_dirty or Engine.is_editor_hint():
return
_properties_dirty = false
process_settings()
func _get_recorded_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_properties()
func _get_owned_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_owned_properties()
func _get_recorded_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
func _get_owned_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()

Some files were not shown because too many files have changed in this diff Show More