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:
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
Binary file not shown.
@@ -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.
Binary file not shown.
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>
|
||||
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_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>
|
||||
-128
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://baiaiot8klmbb
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://delieuoth6q2q
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtvhy5tkh2hjv
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://fqjgqcv4hnjn
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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]
|
||||
@@ -0,0 +1 @@
|
||||
uid://c46ub48lgga12
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://djknl4n6akxue
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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].
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user