Fresh start: replace with naxIO/netfox-cs-sample foundation

Complete replacement of the tactical-shooter project with the
netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built
with Godot 4 and netfox.

## What's new
- Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu
- 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP
- Bomb plant/defuse with 2 bombsites
- Flashbang & smoke grenades
- Proper netfox rollback netcode at 64 tick
- Network popup UI for host/join
- HUD, crosshair, round timer, scoreboard
- All netfox singletons registered as autoloads (works in exported builds)

## Architecture
- Listen-server (host from client, no dedicated server binary)
- Multiplayer-fps game lives at examples/multiplayer-fps/
- Netfox addons registered as autoloads for exported build compat
- Godot 4.7 with Forward+ renderer

## Removed
- Old headless-server architecture (client_main, server_main, player.gd, etc.)
- Custom netfox bootstrap with ENet fallback
- Old ChaffGames FPS template (2,420 lines, 844 KB)
- SimulationServer GDExtension stub
- Godot-jolt physics (netfox sample uses default Godot physics)
- Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc.
- Server browser API Python venv (87 MB)
- test_range map and modular assets

## Preserved
- Git history
- Server config at config/default_server_config.cfg
- Windows export preset
- Build directory (gitignored)

Co-authored-by: naxIO <naxIO@users.noreply.github.com>
This commit is contained in:
2026-07-02 20:55:20 -04:00
parent ce39b237c3
commit b0c83af092
4416 changed files with 57418 additions and 902676 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"permissions": {
"allow": [
"Bash(gh repo:*)",
"WebFetch(domain:godotengine.org)",
"Bash(gh api:*)",
"Bash(which npx:*)",
"Bash(claude mcp:*)",
"mcp__godot__get_godot_status",
"mcp__godot__read_scene",
"mcp__godot__get_input_map",
"mcp__godot__add_node",
"mcp__godot__attach_script",
"mcp__godot__remove_node",
"mcp__godot__configure_input_map",
"mcp__godot__get_errors",
"mcp__godot__clear_console_log",
"mcp__godot__validate_script",
"mcp__godot__get_node_properties",
"mcp__godot__get_console_log",
"Bash(git remote:*)",
"Bash(git add:*)"
]
}
}
+2
View File
@@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
+19 -14
View File
@@ -1,12 +1,18 @@
# Godot
.godot/
import/
*.import
*.translation
*.tres~
*.tscn~
*.godot
# OS
.DS_Store
Thumbs.db
# Build output
build/
# Editor
.idea/
.vscode/
@@ -14,18 +20,17 @@ import/
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Python
__pycache__/
*.pyc
# Build
client/build/
server/build/
*.zip
*.exe
*.pck
# Netfox
addons/netfox/encoder/*.gd
addons/netfox/extras/plugin.cfg
addons/netfox/icons/*.svg.import
addons/netfox/internals/plugin.cfg
addons/netfox/noray/plugin.cfg
addons/netfox/plugin.cfg
# Secrets (operator must create per deployment)
server/data/rcon_password.cfg
build/
build/
# Secrets
rcon_password.cfg
+69
View File
@@ -0,0 +1,69 @@
# Netfox Project
## Projekt-Kontext
Wir arbeiten am **Multiplayer-FPS-Beispiel** unter `/examples/multiplayer-fps/`.
Godot 4.6 mit dem netfox Networking-Framework. Sprache: GDScript.
## Multiplayer-FPS Struktur
**Architektur:** Server-autoritativ mit Rollback (Client-Side Prediction)
### Dateien
- `multiplayer-fps.tscn` — Hauptszene (Map, 5 Spawn Points, UI, Network)
- `characters/player.tscn` — Spieler-Prefab (CharacterBody3D)
- `scripts/player.gd` — Bewegung (Speed 5, Jump 5), Health (100 HP), Respawn
- `scripts/player-input.gd` — Input-Gathering (extends `BaseNetInput`), Mouse Sensitivity 0.005
- `scripts/player-weapon.gd` — Hitscan-Waffe (extends `NetworkWeaponHitscan3D`), 34 DMG/Hit, 0.25s Cooldown
- `scripts/player-spawner.gd` — Spieler spawnen/despawnen bei Connect/Disconnect
- `scripts/bullethole.gd` — Bullet-Hole Decal-Pool (max 20)
- `scripts/ui/` — Crosshair, Health-Bar, 3D-Projection, Window-Resize
### Netzwerk-Setup
- `RollbackSynchronizer` synct: transform, velocity, head rotation
- Inputs: movement, jump, fire, look_angle
- `MultiplayerSynchronizer` repliziert: health, death_tick, respawn_position
- `TickInterpolator` für smooth visuals zwischen Ticks
- Avatar-Body Authority: Server (ID 1), Input-Authority: jeweiliger Peer
### Gameplay
- WASD + Maus FPS-Steuerung
- 3 Hits = Tod (3x34 = 102 > 100 HP)
- Respawn an deterministischem Spawn Point (Hash-basiert)
- Sounds: fire.mp3, hit.wav, death.wav
## Netfox Framework (Addon)
### Kern-Systeme (Autoloads)
- `NetworkTime` — Zentraler Tick-Loop (Projekt-Default: 24 Hz)
- `NetworkTimeSynchronizer` — Clock-Sync zum Server
- `NetworkRollback` — Orchestriert Rollback
- `NetworkEvents` — Event-Broadcasting (on_client_start, on_server_start, on_peer_join, etc.)
- `RollbackSimulationServer` — Führt Physics/Logic während Rollback aus
- `NetworkHistoryServer` — State/Input History
- `NetworkSynchronizationServer` — Paket-Übertragung
- `NetworkIdentityServer` — Node-ID-Mapping
### Custom Nodes
- `RollbackSynchronizer` — Deterministische State-Sync mit Rollback + `_rollback_tick(delta, tick, is_fresh)`
- `StateSynchronizer` — Einfachere State-Sync ohne Rollback
- `TickInterpolator` — Smooth Interpolation zwischen Ticks
- `RewindableAction` — Rollback-fähige Actions (fire, abilities)
- `PredictiveSynchronizer` — Client-Side Prediction
### Extras (netfox.extras)
- `BaseNetInput` — Basis-Klasse für Input-Gathering mit `_gather()`
- `NetworkWeaponHitscan3D` — Hitscan-Waffen mit Latenz-Kompensation
- `NetworkWeapon3D/2D` — Projektil-Waffen
- `RewindableStateMachine` / `RewindableState` — Rollback-fähige State Machine
- `NetworkRigidBody2D/3D` — Physics-Sync mit Rollback
- `NetworkSimulator` — Latenz/Packet-Loss Simulation
- `RewindableRandomNumberGenerator` — Deterministische RNG
### Noray (netfox.noray)
- NAT Punchthrough + Relay-Fallback
- OpenID/PrivateID System
## Doku
- Framework-Docs: `/docs/netfox/`, `/docs/netfox.extras/`, `/docs/netfox.noray/`
- Tutorials: `/docs/netfox/tutorials/`
- Guides: `/docs/netfox/guides/`
+15 -15
View File
@@ -1,18 +1,18 @@
MIT License
Copyright 2023 Gálffy Tamás
Copyright (c) 2026 shawn
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:
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 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.
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.
-141
View File
@@ -1,141 +0,0 @@
# Project: Tactical Shooter (CS-Clone) — Build Plan
## 1. Vision
A competitive, round-based tactical FPS (Counter-Strike-style: buy phase, bomb/objective rounds, economy) built in **Godot 4**, targeting:
- Good visual fidelity on low/mid-end hardware (Valorant-tier art direction, not AAA realism)
- 128-tick authoritative dedicated servers, community-hostable
- Official map-making tools/SDK so the community can build and distribute maps
- Server browser + map distribution pipeline (Steam-Workshop-like, but self-hosted)
## 2. Core Tech Decisions (locked)
| Area | Decision |
|---|---|
| Engine | Godot 4, Forward+ renderer |
| Server model | Authoritative dedicated server, headless Godot (`--headless`) export |
| Netcode | GDExtension (C++) simulation core from day one; ENet or GodotSteam transport (evaluate both in Phase 0) |
| Tick rate | **128 tick, firm target.** Simulation hot path built in GDExtension (C++) from day one — not GDScript — to make this realistic. See Section 3a. |
| Lighting | Baked lightmaps (LightmapGI) + reflection probes, no SDFGI/real-time GI |
| Map tooling | Built-in CSG nodes (Hammer-like blockout) + custom map template project |
| Hit detection | Server-side hitscan with lag compensation (rewind) |
| Distribution | Self-hosted CDN/GitHub releases for `.pck` map bundles, custom server browser API |
## 3a. Notes on Hitting 128 Tick
128 tick is achievable in Godot but requires committing to the harder architecture from the start, not retrofitting it later:
- **Simulation core in GDExtension (C++), not GDScript.** Movement, hit detection, and state serialization for the authoritative server should live in a compiled GDExtension module. GDScript can drive high-level game logic (round state, economy, UI) but should not be in the 128Hz hot loop.
- **Delta-compressed snapshots, not full state per tick.** At 128Hz, sending full world state every tick is a bandwidth non-starter. Serialize only changed fields per entity per tick.
- **Decouple simulation tick from network send rate.** The server can simulate at 128Hz while only sending snapshots to clients at a lower rate (e.g. 64Hz) with interpolation — this is standard practice (Source engine itself decouples these). Confirm with the team whether "128 tick" means simulation rate, network send rate, or both — this materially changes the engineering plan.
- **Benchmark in Phase 0, not Phase 1.** Stand up a synthetic load test (bots or dummy clients) hitting 128Hz simulation with realistic player counts (10v10) before building gameplay on top of it. If GDExtension can't sustain it on target hardware, that needs to be known before Phase 1 starts, not discovered mid-project.
- **Physics FPS setting**: Godot's `_physics_process` must be set to 128 (Project Settings → Physics → Common → Physics Ticks Per Second) and profiled for headroom, since this affects both simulation and physics engine cost.
## 3b. Libraries & Plugins
| Plugin/Library | Purpose | Notes | Owning Agent |
|---|---|---|---|
| **[FuncGodot](https://github.com/func-godot/func_godot_plugin)** | Import TrenchBroom `.map`/`.vmf`-style files into Godot as scenes | Successor to Qodot; gives community a Hammer/TrenchBroom-equivalent mapping workflow — evaluate as primary mapping tool over raw CSG | Mapping SDK Agent |
| **[godot-tbloader](https://github.com/codecat/godot-tbloader)** | Alternative TrenchBroom loader | Lighter-weight C++ port; benchmark against FuncGodot in Phase 0/5, pick one | Mapping SDK Agent |
| **[GodotSteam](https://godotsteam.com) (GDExtension)** | Low-level networking transport (wraps GameNetworkingSockets) | Works over plain IP, not just Steam lobbies; strong candidate to replace/augment ENet for reliability and lane prioritization | Netcode Agent |
| **GodotSteam Server (GDExtension)** | Server-side counterpart to GodotSteam | Useful if considering optional Steam-based server discovery later | Server Infra Agent |
| **[steam-multiplayer-peer](https://github.com/expressobits/steam-multiplayer-peer)** | MultiplayerPeer implementation over Steam Sockets | Only relevant if adopting Godot's high-level multiplayer API on top of Steam transport | Netcode Agent |
| ENet (built-in) | Baseline transport | Keep as Phase 0 fallback/reference implementation while evaluating GodotSteam | Netcode Agent |
| Custom GDExtension (C++) | Simulation hot path | Not a plugin — build in-house. See Section 3a. | Netcode Agent |
**Deliberately not using:**
- **GD-Sync** — relay-based hosted multiplayer service; conflicts with the self-hosted community dedicated server model.
- **godot-rollback-netcode** (Snopek Games) — built for rollback-style netcode (fighting games), not authoritative-server shooters. Wrong model for this project.
## 4. Phases & Milestones
### Phase 0 — Foundations (spike/prototype)
- Stand up empty Godot project, Forward+ renderer configured
- Headless dedicated server export pipeline working (`--headless`, build + run on Linux VPS)
- Basic client-server connection via ENet or GodotSteam transport, player join/leave, position replication
- GDExtension simulation core scaffolded; synthetic load test at 128Hz with realistic player counts (see Section 3a)
- **Exit criteria:** two clients can connect to a dedicated server and see each other move, and the 128Hz load test hits target headroom on reference hardware
### Phase 1 — Core Gameplay Loop
- First-person character controller (movement, jump, crouch, walk toggle)
- Client-side prediction + server reconciliation for movement
- Hitscan weapon (one gun), server-authoritative damage, lag compensation
- Round system: round start/end, win condition (elimination), respawn/spectate
- One grey-box test map
- **Exit criteria:** two players can shoot each other and complete a round on the dedicated server
### Phase 2 — Tactical Shooter Systems
- Buy menu + economy (money per round, win/loss bonuses)
- Bomb-defuse or objective mode (plant/defuse or equivalent)
- Weapon set (rifle, pistol, sniper, utility — smoke/flash/grenade)
- Team system, spawn zones, buy zones
- **Exit criteria:** full round loop (buy → play → win/loss → economy carries over) playable end-to-end
### Phase 3 — Visuals & Performance Pass
- Baked lighting pass on test map (LightmapGI), reflection probes
- Art pass: modular wall/floor kit, PBR materials at 1K, agreed art style guide
- Performance budget defined (target frame time on reference low-end GPU) and profiled
- LOD + occlusion culling setup
- **Exit criteria:** test map hits performance budget on reference low-end hardware while looking presentable
### Phase 4 — Dedicated Server Hardening
- RCON-style remote admin console
- Server config files (tick rate, map rotation, cvars)
- Master server / server browser API (heartbeat + query)
- Anti-cheat basics (server-authoritative validation, sanity checks on inputs)
- **Exit criteria:** community member can download server binary, configure, and have it appear in a public server browser
### Phase 5 — Mapmaking SDK
- Map template Godot project (CSG-based prefabs, spawn/buy-zone/bombsite node types documented)
- Lightmap baking + validation script (poly count, texture size, light count linting)
- Map packaging as `.pck` addon + auto-download on client connect
- Mapping documentation for community
- **Exit criteria:** a team member unfamiliar with the codebase can build and ship a working custom map using only the SDK + docs
### Phase 6 — Polish & Ecosystem
- Plugin/scripting hook API for server admins (SourceMod-style, stretch goal)
- Workshop-style map browser in-client
- Bug bash, netcode edge cases (packet loss, high ping simulation testing)
- **Exit criteria:** ready for closed community playtest
## 5. Repository Structure (proposed)
```
/client Godot client project
/server Headless server export config/scripts
/shared Code shared between client & server (game logic, data structs)
/maps
/template Base map project agents/community start from
/test_maps
/tools
/map-validator Lint script for poly count, texture size, lighting
/server-browser-api Master server service
/docs
/architecture.md
/netcode.md
/mapping-sdk.md
/server-hosting.md
```
## 6. Workstream Ownership (for agent team assignment)
| Workstream | Scope |
|---|---|
| **Netcode Agent** | GDExtension simulation core, client prediction, server reconciliation, lag compensation, 128Hz tick loop, snapshot delta compression, transport layer (ENet/GodotSteam) |
| **Gameplay Agent** | Movement controller, weapons, round/economy logic, buy system |
| **Rendering/Art Pipeline Agent** | Forward+ renderer config, LightmapGI baked lighting setup, material/texture standards, performance profiling |
| **Server Infra Agent** | Headless export pipeline, RCON, config system, master server/server browser |
| **Mapping SDK Agent** | Map template project, CSG prefab kit, validation/lint tooling, `.pck` packaging pipeline |
| **Docs/Integration Agent** | Keeps `/docs` in sync, defines interfaces between workstreams, integration testing |
## 7. Key Risks to Flag Early
- **128 tick is the firm target — treat it as a Phase 0 gate, not a later optimization.** If the load test in Phase 0 doesn't hit target headroom, stop and resolve it before Phase 1 gameplay work begins; don't build gameplay on an unproven simulation core.
- **GDScript must stay out of the 128Hz hot loop.** All performance-critical simulation code goes in the GDExtension core from day one (see Section 3a) — this is not optional at this tick rate.
- **Clarify what "128 tick" covers** with the team up front: simulation rate, network send rate, or both. This changes the bandwidth budget and delta-compression design significantly.
- **Scope**: a full CS clone (weapon balance, anti-cheat, matchmaking, many maps) is large. Phases above are ordered so there's a playable end-to-end loop as early as Phase 2 — resist adding features before that loop is solid.
- **Lag compensation correctness** is the single most likely source of "feels bad" gameplay bugs — budget real testing time here, not just implementation time.
## 8. Definition of Done for Each Phase
Each phase is done when its exit criteria (above) is met, testable by two or more clients connecting to an actual dedicated server build (not just in-editor play mode), with no P0/P1 bugs open in that phase's scope.
+91 -8
View File
@@ -1,11 +1,94 @@
# Tactical Shooter
# netfox CS Sample
A competitive, round-based tactical FPS built in Godot 4.
A **Counter-Strike 1.6 inspired** multiplayer FPS built with [Godot 4.6](https://godotengine.org/) and the [netfox](https://github.com/foxssake/netfox) networking framework.
- 128-tick authoritative dedicated servers
- GDExtension (C++) simulation core
- Community-hostable, self-hosted server browser
- [Map-making SDK](docs/mapmaking/00-index.md) for community maps
This is a community sample showcasing how to build a server-authoritative FPS with rollback netcode, client-side prediction, and latency compensation using netfox's `RollbackSynchronizer`, `RewindableAction`, and related systems.
See [PLANS/PROJECT_PLAN.md](PLANS/PROJECT_PLAN.md) for the full build plan.
See [Mapmaking SDK docs](docs/mapmaking/00-index.md) to build and ship custom maps.
> **Note:** This is a fork of the [netfox repository](https://github.com/foxssake/netfox). The game lives in `examples/multiplayer-fps/`.
## Features
- **Teams & Rounds** — Terrorists vs Counter-Terrorists with freeze-time, round timer, and win conditions
- **6 Weapons** — Knife, Glock, USP, AK-47, M4A1, AWP with per-weapon damage, fire rate, recoil, ammo, and reload
- **Economy System** — Kill/round rewards, loss streak bonus, buy menu (B-key), Kevlar & defuse kit
- **Bomb Plant/Defuse** — 2 bombsites, server-authoritative bomb logic, carrier tracking, drop on death
- **Grenades** — Flashbang (LOS + distance whiteout) and Smoke grenades
- **Rollback Netcode** — Server-authoritative with client-side prediction at 64 tick
- **Latency-Compensated Weapons** — `RewindableAction`-based hitscan firing inside the rollback loop
- **Frame-Rate Camera** — Mouse look renders at display refresh rate, not tick rate
## How to Run
1. Clone this repo
2. Open the project root in **Godot 4.6**
3. Run the main scene (`examples/multiplayer-fps/multiplayer-fps.tscn`)
4. Use the network popup to host/join a game
For multiple local clients, enable **auto-tile windows** in the netfox settings (already on by default).
## Controls
| Key | Action |
|---|---|
| WASD | Move |
| Mouse | Look |
| Left Click | Fire |
| R | Reload |
| 1-4 | Weapon slots |
| Scroll | Next/prev weapon |
| B | Buy menu |
| E | Use (plant/defuse bomb) |
| Tab | Scoreboard |
| Esc | Release mouse |
## Project Structure
```
examples/multiplayer-fps/
├── multiplayer-fps.tscn # Main scene (map, spawns, UI, network)
├── characters/player.tscn # Player prefab (CharacterBody3D)
├── scripts/
│ ├── player.gd # Movement, health, respawn
│ ├── player-input.gd # Input gathering (BaseNetInput)
│ ├── weapon_manager.gd # Weapon switching, ammo, models
│ ├── round-manager.gd # Round state machine, win conditions
│ ├── team-manager.gd # Team assignment (T/CT)
│ ├── economy_manager.gd # Money, buy logic, rewards
│ ├── bomb.gd # Bomb plant/defuse/explode
│ ├── bombsite.gd # Bombsite area detection
│ ├── grenade.gd # Flash & smoke grenades
│ ├── bullethole.gd # Decal pool
│ ├── node-pool.gd # Object pooling utility
│ ├── player-spawner.gd # Player spawn/despawn on connect
│ ├── data/weapon_data.gd # Weapon stats resource
│ ├── data/weapon_registry.gd # Weapon ID → resource mapping
│ └── ui/ # HUD, buy menu, bomb HUD, crosshair
addons/
├── netfox/ # Core: timing, rollback, synchronizers
├── netfox.extras/ # Weapons, input, state machines
├── netfox.internals/ # Internal utilities
└── netfox.noray/ # NAT punchthrough & relay
```
## Netfox Patterns Used
- **`RollbackSynchronizer`** with `_rollback_tick()` for deterministic state sync
- **`RewindableAction`** for rollback-safe weapon firing
- **`TickInterpolator`** for smooth visuals between ticks
- **`BaseNetInput`** for input gathering with `_gather()`
- **`NetworkEvents`** for peer join/leave handling
- **`MultiplayerSynchronizer`** for non-rollback state (health, death, economy)
- **Self-RPC pattern** — direct-call for listen-server host, RPC for clients
## License
MIT — see [LICENSE](LICENSE).
Built on top of [netfox](https://github.com/foxssake/netfox) by [Fox's Sake Studio](https://foxssake.studio/).
## Credits
- [netfox](https://github.com/foxssake/netfox) — Networking framework by Tamás Gálffy / Fox's Sake
- Sound effects from [Sonniss GDC Bundle](https://sonniss.com/)
- Bullet hole texture by [musdasch](https://opengameart.org/users/musdasch) (CC0)
- Crosshair by [krazyjakee](https://github.com/krazyjakee) (CC0)
-18
View File
@@ -1,18 +0,0 @@
Copyright (c) Mikael Hermansson and Godot Jolt contributors.
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.
-91
View File
@@ -1,91 +0,0 @@
Godot Jolt incorporates third-party material from the projects listed below.
Godot Engine (https://github.com/godotengine/godot)
Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md).
Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
godot-cpp (https://github.com/godot-jolt/godot-cpp)
Copyright (c) 2017-present Godot Engine contributors.
Copyright (c) 2022-present Mikael Hermansson.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
Jolt Physics (https://github.com/godot-jolt/jolt)
Copyright (c) 2021 Jorrit Rouwe.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
mimalloc (https://github.com/godot-jolt/mimalloc)
Copyright (c) 2018-2021 Microsoft Corporation, Daan Leijen.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
-41
View File
@@ -1,41 +0,0 @@
[godot-jolt]
version = "0.16.0-stable"
build = "8eca5bb795"
[configuration]
entry_symbol = "godot_jolt_main"
compatibility_minimum = "4.3"
compatibility_maximum = "4.7"
[libraries]
windows.release.single.x86_64 = "windows/godot-jolt_windows-x64.dll"
windows.debug.single.x86_64 = "windows/godot-jolt_windows-x64_editor.dll"
windows.release.single.x86_32 = "windows/godot-jolt_windows-x86.dll"
windows.debug.single.x86_32 = "windows/godot-jolt_windows-x86_editor.dll"
linux.release.single.x86_64 = "linux/godot-jolt_linux-x64.so"
linux.debug.single.x86_64 = "linux/godot-jolt_linux-x64_editor.so"
linux.release.single.x86_32 = "linux/godot-jolt_linux-x86.so"
linux.debug.single.x86_32 = "linux/godot-jolt_linux-x86_editor.so"
macos.release.single = "macos/godot-jolt_macos.framework"
macos.debug.single = "macos/godot-jolt_macos_editor.framework"
ios.release.single = "ios/godot-jolt_ios.framework"
ios.debug.single = "ios/godot-jolt_ios_editor.framework"
android.release.single.arm64 = "android/libgodot-jolt_android-arm64.so"
android.debug.single.arm64 = "android/libgodot-jolt_android-arm64_editor.so"
android.release.single.arm32 = "android/libgodot-jolt_android-arm32.so"
android.debug.single.arm32 = "android/libgodot-jolt_android-arm32_editor.so"
android.release.single.x86_64 = "android/libgodot-jolt_android-x64.so"
android.debug.single.x86_64 = "android/libgodot-jolt_android-x64_editor.so"
android.release.single.x86_32 = "android/libgodot-jolt_android-x86.so"
android.debug.single.x86_32 = "android/libgodot-jolt_android-x86_editor.so"
@@ -1 +0,0 @@
uid://bc7tkad04qybu
@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>godot-jolt_ios</string>
<key>CFBundleName</key>
<string>Godot Jolt</string>
<key>CFBundleDisplayName</key>
<string>Godot Jolt</string>
<key>CFBundleIdentifier</key>
<string>org.godot-jolt.godot-jolt</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
<key>CFBundleVersion</key>
<string>0.16.0</string>
<key>CFBundleShortVersionString</key>
<string>0.16.0</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>CSResourcesFileMapped</key>
<true/>
<key>DTPlatformName</key>
<string>iphoneos</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<!--
HACK(mihe): This is to work around a bug in Godot 4.3-beta1, where it treats Framework
bundles the same as XCFramework bundles, and expects there to be an `AvailableLibraries`
entry, which is really only a thing in XCFramework bundles. Note that we also lie about the
binary path having a `.dylib` extension in order for Godot to correctly identify this as a
dynamically linked bundle.
-->
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>godot-jolt_ios.dylib</string>
</dict>
</array>
</dict>
</plist>
@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>godot-jolt_ios_editor</string>
<key>CFBundleName</key>
<string>Godot Jolt</string>
<key>CFBundleDisplayName</key>
<string>Godot Jolt</string>
<key>CFBundleIdentifier</key>
<string>org.godot-jolt.godot-jolt</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
<key>CFBundleVersion</key>
<string>0.16.0</string>
<key>CFBundleShortVersionString</key>
<string>0.16.0</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>CSResourcesFileMapped</key>
<true/>
<key>DTPlatformName</key>
<string>iphoneos</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<!--
HACK(mihe): This is to work around a bug in Godot 4.3-beta1, where it treats Framework
bundles the same as XCFramework bundles, and expects there to be an `AvailableLibraries`
entry, which is really only a thing in XCFramework bundles. Note that we also lie about the
binary path having a `.dylib` extension in order for Godot to correctly identify this as a
dynamically linked bundle.
-->
<key>AvailableLibraries</key>
<array>
<dict>
<key>BinaryPath</key>
<string>godot-jolt_ios_editor.dylib</string>
</dict>
</array>
</dict>
</plist>
Binary file not shown.
Binary file not shown.
@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>godot-jolt_macos</string>
<key>CFBundleName</key>
<string>Godot Jolt</string>
<key>CFBundleDisplayName</key>
<string>Godot Jolt</string>
<key>CFBundleIdentifier</key>
<string>org.godot-jolt.godot-jolt</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
<key>CFBundleVersion</key>
<string>0.16.0</string>
<key>CFBundleShortVersionString</key>
<string>0.16.0</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CSResourcesFileMapped</key>
<true/>
<key>DTPlatformName</key>
<string>macosx</string>
<key>LSMinimumSystemVersion</key>
<string>10.12</string>
</dict>
</plist>
@@ -1,128 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict>
<key>Resources/Info.plist</key>
<data>
PLFiiedGCgBUVIdXAwIQZP3TzhU=
</data>
</dict>
<key>files2</key>
<dict>
<key>Resources/Info.plist</key>
<dict>
<key>hash2</key>
<data>
oUfCierc8a2c8bsFW1mu5lCv8dOCnLWbP/Ju8VP12gk=
</data>
</dict>
</dict>
<key>rules</key>
<dict>
<key>^Resources/</key>
<true/>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^.*</key>
<true/>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^[^/]+$</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>
@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>godot-jolt_macos_editor</string>
<key>CFBundleName</key>
<string>Godot Jolt</string>
<key>CFBundleDisplayName</key>
<string>Godot Jolt</string>
<key>CFBundleIdentifier</key>
<string>org.godot-jolt.godot-jolt</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
<key>CFBundleVersion</key>
<string>0.16.0</string>
<key>CFBundleShortVersionString</key>
<string>0.16.0</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CSResourcesFileMapped</key>
<true/>
<key>DTPlatformName</key>
<string>macosx</string>
<key>LSMinimumSystemVersion</key>
<string>10.12</string>
</dict>
</plist>
@@ -1,128 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict>
<key>Resources/Info.plist</key>
<data>
xxh2HxAbRksJ2pI8miqkcxNuMMY=
</data>
</dict>
<key>files2</key>
<dict>
<key>Resources/Info.plist</key>
<dict>
<key>hash2</key>
<data>
HuUpHzJ0lNjh0+cAW+2ottBM6fM0woHZDn2U83XmNzY=
</data>
</dict>
</dict>
<key>rules</key>
<dict>
<key>^Resources/</key>
<true/>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^.*</key>
<true/>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^[^/]+$</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>
Binary file not shown.
Binary file not shown.
+156
View File
@@ -0,0 +1,156 @@
@tool
extends Node
class_name MCPClient
## WebSocket client for communication with the MCP server.
## Handles connection, reconnection, and message routing.
signal connected
signal disconnected
signal tool_requested(request_id: String, tool_name: String, args: Dictionary)
const DEFAULT_URL := "ws://127.0.0.1:6505"
const RECONNECT_DELAY := 3.0
const MAX_RECONNECT_DELAY := 30.0
const MAX_PACKETS_PER_FRAME := 32
var socket: WebSocketPeer = WebSocketPeer.new()
var server_url: String = DEFAULT_URL
var _is_connected := false
var _reconnect_timer: Timer
var _current_reconnect_delay := RECONNECT_DELAY
var _should_reconnect := true
var _project_path: String
var _initialized := false
func _ready() -> void:
_project_path = ProjectSettings.globalize_path("res://")
# Create reconnect timer
_reconnect_timer = Timer.new()
_reconnect_timer.one_shot = true
_reconnect_timer.timeout.connect(_on_reconnect_timer)
add_child(_reconnect_timer)
_initialized = true
func _process(_delta: float) -> void:
if not _initialized:
return
if socket.get_ready_state() == WebSocketPeer.STATE_CLOSED:
if _is_connected:
_handle_disconnect()
return
socket.poll()
match socket.get_ready_state():
WebSocketPeer.STATE_OPEN:
if not _is_connected:
_handle_connect()
var packets_processed := 0
while socket.get_available_packet_count() > 0 and packets_processed < MAX_PACKETS_PER_FRAME:
_handle_message(socket.get_packet().get_string_from_utf8())
packets_processed += 1
WebSocketPeer.STATE_CLOSING:
pass # Wait for close
WebSocketPeer.STATE_CLOSED:
if _is_connected:
_handle_disconnect()
func connect_to_server(url: String = DEFAULT_URL) -> void:
server_url = url
_should_reconnect = true
_current_reconnect_delay = RECONNECT_DELAY
_attempt_connection()
func disconnect_from_server() -> void:
_should_reconnect = false
if _reconnect_timer:
_reconnect_timer.stop()
if socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
socket.close()
_is_connected = false
func _attempt_connection() -> void:
if socket.get_ready_state() != WebSocketPeer.STATE_CLOSED:
socket.close()
print("[MCP] Connecting to ", server_url, "...")
var err := socket.connect_to_url(server_url)
if err != OK:
push_error("[MCP] Failed to connect: ", err)
_schedule_reconnect()
func _handle_connect() -> void:
_is_connected = true
_current_reconnect_delay = RECONNECT_DELAY # Reset backoff
print("[MCP] Connected to server")
# Send godot_ready message with project info
_send_message({
&"type": &"godot_ready",
&"project_path": _project_path,
})
connected.emit()
func _handle_disconnect() -> void:
_is_connected = false
print("[MCP] Disconnected from server")
disconnected.emit()
if _should_reconnect:
_schedule_reconnect()
func _schedule_reconnect() -> void:
if not _reconnect_timer:
return
print("[MCP] Reconnecting in ", _current_reconnect_delay, " seconds...")
_reconnect_timer.start(_current_reconnect_delay)
# Exponential backoff
_current_reconnect_delay = min(_current_reconnect_delay * 2, MAX_RECONNECT_DELAY)
func _on_reconnect_timer() -> void:
_attempt_connection()
func _handle_message(json_string: String) -> void:
var message = JSON.parse_string(json_string)
if message == null:
push_error("[MCP] Failed to parse message: ", json_string)
return
var msg_type: String = message.get(&"type", "")
match msg_type:
"ping":
_send_message({&"type": &"pong"})
"tool_invoke":
var request_id: String = message.get(&"id", "")
var tool_name: String = message.get(&"tool", "")
var args: Dictionary = message.get(&"args", {})
print("[MCP] Tool request: ", tool_name, " (", request_id, ")")
tool_requested.emit(request_id, tool_name, args)
_:
print("[MCP] Unknown message type: ", msg_type)
func send_tool_result(request_id: String, success: bool, result = null, error: String = "") -> void:
var response := {
&"type": &"tool_result",
&"id": request_id,
&"success": success,
}
if success:
response[&"result"] = result
else:
response[&"error"] = error
_send_message(response)
print("[MCP] Sent result for ", request_id, " (success=", success, ")")
func _send_message(message: Dictionary) -> void:
if socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
socket.send_text(JSON.stringify(message))
func is_connected_to_server() -> bool:
return _is_connected
+1
View File
@@ -0,0 +1 @@
uid://baiaiot8klmbb
+7
View File
@@ -0,0 +1,7 @@
[plugin]
name="Godot MCP"
description="MCP server integration for AI assistants. Connects to the godot-mcp-server via WebSocket."
author="Godot MCP"
version="0.2.6"
script="plugin.gd"
+89
View File
@@ -0,0 +1,89 @@
@tool
extends EditorPlugin
## Godot MCP Plugin
## Connects to the godot-mcp-server via WebSocket and executes tools.
const MCPClientScript = preload("res://addons/godot_mcp/mcp_client.gd")
const ToolExecutorScript = preload("res://addons/godot_mcp/tool_executor.gd")
var _mcp_client: Node # MCPClient
var _tool_executor: Node # ToolExecutor
var _status_label: Label
func _enter_tree() -> void:
print("[Godot MCP] Plugin loading...")
# Create MCP client
_mcp_client = MCPClientScript.new()
_mcp_client.name = "MCPClient"
add_child(_mcp_client)
# Create tool executor
_tool_executor = ToolExecutorScript.new()
_tool_executor.name = "ToolExecutor"
add_child(_tool_executor) # _ready() runs here, creating child tools
_tool_executor.set_editor_plugin(self) # Now _visualizer_tools exists
# Connect signals
_mcp_client.connected.connect(_on_connected)
_mcp_client.disconnected.connect(_on_disconnected)
_mcp_client.tool_requested.connect(_on_tool_requested)
# Add status indicator to editor
_setup_status_indicator()
# Start connection
_mcp_client.connect_to_server()
print("[Godot MCP] Plugin loaded - connecting to MCP server...")
func _exit_tree() -> void:
print("[Godot MCP] Plugin unloading...")
if _mcp_client:
_mcp_client.disconnect_from_server()
_mcp_client.queue_free()
if _tool_executor:
_tool_executor.queue_free()
if _status_label:
remove_control_from_container(EditorPlugin.CONTAINER_TOOLBAR, _status_label)
_status_label.queue_free()
print("[Godot MCP] Plugin unloaded")
func _setup_status_indicator() -> void:
"""Add a small status label to the editor toolbar."""
_status_label = Label.new()
_status_label.text = "MCP: Connecting..."
_status_label.add_theme_color_override("font_color", Color.YELLOW)
_status_label.add_theme_font_size_override("font_size", 12)
add_control_to_container(EditorPlugin.CONTAINER_TOOLBAR, _status_label)
func _on_connected() -> void:
print("[Godot MCP] Connected to MCP server")
if _status_label:
_status_label.text = "MCP: Connected"
_status_label.add_theme_color_override("font_color", Color.GREEN)
func _on_disconnected() -> void:
print("[Godot MCP] Disconnected from MCP server")
if _status_label:
_status_label.text = "MCP: Disconnected"
_status_label.add_theme_color_override("font_color", Color.RED)
func _on_tool_requested(request_id: String, tool_name: String, args: Dictionary) -> void:
"""Handle incoming tool request from MCP server."""
print("[Godot MCP] Executing tool: ", tool_name)
# Execute the tool
var result: Dictionary = _tool_executor.execute_tool(tool_name, args)
var success: bool = result.get(&"ok", false)
if success:
result.erase(&"ok")
_mcp_client.send_tool_result(request_id, true, result)
else:
var error: String = result.get(&"error", "Unknown error")
_mcp_client.send_tool_result(request_id, false, null, error)
+1
View File
@@ -0,0 +1 @@
uid://delieuoth6q2q
+144
View File
@@ -0,0 +1,144 @@
@tool
extends Node
class_name ToolExecutor
## Routes tool invocations to the appropriate handler.
var _editor_plugin: EditorPlugin = null
var _file_tools: Node
var _scene_tools: Node
var _script_tools: Node
var _project_tools: Node
var _asset_tools: Node
var _visualizer_tools: Node
# Tool name → [handler_node, method_name]
var _tool_map: Dictionary = {}
var _initialized := false
func _init_tools() -> void:
"""Initialize all tool handlers. Called from set_editor_plugin."""
if _initialized:
return
_initialized = true
_file_tools = preload("res://addons/godot_mcp/tools/file_tools.gd").new()
_file_tools.name = "FileTools"
add_child(_file_tools)
_scene_tools = preload("res://addons/godot_mcp/tools/scene_tools.gd").new()
_scene_tools.name = "SceneTools"
add_child(_scene_tools)
_script_tools = preload("res://addons/godot_mcp/tools/script_tools.gd").new()
_script_tools.name = "ScriptTools"
add_child(_script_tools)
_project_tools = preload("res://addons/godot_mcp/tools/project_tools.gd").new()
_project_tools.name = "ProjectTools"
add_child(_project_tools)
_asset_tools = preload("res://addons/godot_mcp/tools/asset_tools.gd").new()
_asset_tools.name = "AssetTools"
add_child(_asset_tools)
_visualizer_tools = preload("res://addons/godot_mcp/tools/visualizer_tools.gd").new()
_visualizer_tools.name = "VisualizerTools"
add_child(_visualizer_tools)
# Build tool routing map
_tool_map = {
&"list_dir": [_file_tools, &"list_dir"],
&"read_file": [_file_tools, &"read_file"],
&"search_project": [_file_tools, &"search_project"],
&"create_script": [_file_tools, &"create_script"],
&"create_scene": [_scene_tools, &"create_scene"],
&"read_scene": [_scene_tools, &"read_scene"],
&"add_node": [_scene_tools, &"add_node"],
&"remove_node": [_scene_tools, &"remove_node"],
&"modify_node_property": [_scene_tools, &"modify_node_property"],
&"rename_node": [_scene_tools, &"rename_node"],
&"move_node": [_scene_tools, &"move_node"],
&"attach_script": [_scene_tools, &"attach_script"],
&"detach_script": [_scene_tools, &"detach_script"],
&"set_collision_shape": [_scene_tools, &"set_collision_shape"],
&"set_sprite_texture": [_scene_tools, &"set_sprite_texture"],
&"get_scene_hierarchy": [_scene_tools, &"get_scene_hierarchy"],
&"get_scene_node_properties": [_scene_tools, &"get_scene_node_properties"],
&"set_scene_node_property": [_scene_tools, &"set_scene_node_property"],
&"edit_script": [_script_tools, &"edit_script"],
&"validate_script": [_script_tools, &"validate_script"],
&"list_scripts": [_script_tools, &"list_scripts"],
&"create_folder": [_script_tools, &"create_folder"],
&"delete_file": [_script_tools, &"delete_file"],
&"rename_file": [_script_tools, &"rename_file"],
&"get_project_settings": [_project_tools, &"get_project_settings"],
&"list_settings": [_project_tools, &"list_settings"],
&"update_project_settings": [_project_tools, &"update_project_settings"],
&"get_input_map": [_project_tools, &"get_input_map"],
&"configure_input_map": [_project_tools, &"configure_input_map"],
&"get_collision_layers": [_project_tools, &"get_collision_layers"],
&"setup_autoload": [_project_tools, &"setup_autoload"],
&"get_node_properties": [_project_tools, &"get_node_properties"],
&"get_console_log": [_project_tools, &"get_console_log"],
&"get_errors": [_project_tools, &"get_errors"],
&"clear_console_log": [_project_tools, &"clear_console_log"],
&"open_in_godot": [_project_tools, &"open_in_godot"],
&"scene_tree_dump": [_project_tools, &"scene_tree_dump"],
&"generate_2d_asset": [_asset_tools, &"generate_2d_asset"],
&"map_project": [_visualizer_tools, &"map_project"],
&"map_scenes": [_visualizer_tools, &"map_scenes"],
}
func set_editor_plugin(plugin: EditorPlugin) -> void:
_editor_plugin = plugin
# Initialize tools first (must be done synchronously)
_init_tools()
# Pass editor plugin reference to all tool handlers
if _file_tools: _file_tools.set_editor_plugin(plugin)
if _scene_tools: _scene_tools.set_editor_plugin(plugin)
if _script_tools: _script_tools.set_editor_plugin(plugin)
if _project_tools: _project_tools.set_editor_plugin(plugin)
if _asset_tools: _asset_tools.set_editor_plugin(plugin)
if _visualizer_tools:
_visualizer_tools.set_editor_plugin(plugin)
# Pass scene_tools reference for visualizer internal scene functions
_visualizer_tools.set_scene_tools_ref(_scene_tools)
func execute_tool(tool_name: String, args: Dictionary) -> Dictionary:
"""Execute a tool by name with the given arguments."""
# Handle internal visualizer commands (not exposed as MCP tools)
if tool_name.begins_with("visualizer._internal_"):
var method: String = tool_name.replace("visualizer.", "")
if _visualizer_tools and _visualizer_tools.has_method(method):
return _visualizer_tools.call(method, args)
else:
return {&"ok": false, &"error": "Internal method not found: " + method}
if not _tool_map.has(tool_name):
return {
&"ok": false,
&"error": "Unknown tool: %s. Available: %s" % [tool_name, ", ".join(_tool_map.keys())]
}
var handler: Array = _tool_map[tool_name]
var node: Node = handler[0]
var method: StringName = handler[1]
if not node.has_method(method):
return {&"ok": false, &"error": "Tool handler not found: %s.%s" % [node.name, method]}
return node.call(method, args)
func get_available_tools() -> Array:
"""Return list of available tool names."""
return _tool_map.keys()
+1
View File
@@ -0,0 +1 @@
uid://dtvhy5tkh2hjv
+135
View File
@@ -0,0 +1,135 @@
@tool
extends Node
class_name AssetTools
## Asset generation tools for MCP.
## Handles: generate_2d_asset, search_comfyui_nodes,
## inspect_runninghub_workflow, customize_and_run_workflow
var _editor_plugin: EditorPlugin = null
func set_editor_plugin(plugin: EditorPlugin) -> void:
_editor_plugin = plugin
func _refresh_filesystem() -> void:
if _editor_plugin:
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
# =============================================================================
# generate_2d_asset - Generate PNG from SVG code
# =============================================================================
func generate_2d_asset(args: Dictionary) -> Dictionary:
var svg_code: String = str(args.get(&"svg_code", ""))
var filename: String = str(args.get(&"filename", ""))
var save_path: String = str(args.get(&"save_path", "res://assets/generated/"))
if svg_code.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'svg_code'"}
if filename.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'filename'"}
# Ensure .png extension
if not filename.ends_with(".png"):
filename += ".png"
# Ensure save path
if not save_path.begins_with("res://"):
save_path = "res://" + save_path
if not save_path.ends_with("/"):
save_path += "/"
# Create directory if needed
if not DirAccess.dir_exists_absolute(save_path):
DirAccess.make_dir_recursive_absolute(save_path)
# Parse SVG dimensions from the svg_code
var width := 64
var height := 64
# Simple regex-free parsing for width/height
var w_start := svg_code.find("width=\"")
if w_start != -1:
var w_val := svg_code.substr(w_start + 7)
var w_end := w_val.find("\"")
if w_end != -1:
width = int(w_val.substr(0, w_end))
var h_start := svg_code.find("height=\"")
if h_start != -1:
var h_val := svg_code.substr(h_start + 8)
var h_end := h_val.find("\"")
if h_end != -1:
height = int(h_val.substr(0, h_end))
# Create Image from SVG
var image := Image.new()
# Save SVG to temp file, then load as image
var temp_svg_path := "user://temp_asset.svg"
var svg_file := FileAccess.open(temp_svg_path, FileAccess.WRITE)
if not svg_file:
return {&"ok": false, &"error": "Failed to create temp SVG file"}
svg_file.store_string(svg_code)
svg_file.close()
# Load SVG as image
var err := image.load(temp_svg_path)
if err != OK:
# Fallback: try loading SVG data directly
image = Image.create(width, height, false, Image.FORMAT_RGBA8)
image.fill(Color(1, 0, 1, 1)) # Magenta fallback = something went wrong
print("[MCP] Warning: Could not render SVG, created fallback image")
# Clean up temp file
DirAccess.remove_absolute(temp_svg_path)
# Save as PNG
var full_path := save_path + filename
var global_path := ProjectSettings.globalize_path(full_path)
err = image.save_png(global_path)
if err != OK:
return {&"ok": false, &"error": "Failed to save PNG: " + str(err)}
_refresh_filesystem()
return {
&"ok": true,
&"resource_path": full_path,
&"dimensions": {&"width": width, &"height": height},
&"message": "Generated %s (%dx%d)" % [full_path, width, height],
}
# =============================================================================
# search_comfyui_nodes - Stub (requires external database)
# =============================================================================
func search_comfyui_nodes(args: Dictionary) -> Dictionary:
# This tool requires the ComfyUI node database which was bundled with the old plugin
# For now, return a message indicating it needs setup
return {
&"ok": true,
&"results": [],
&"count": 0,
&"message": "ComfyUI node search requires the node database. This feature will be available in a future update.",
}
# =============================================================================
# inspect_runninghub_workflow - Stub (requires API key)
# =============================================================================
func inspect_runninghub_workflow(args: Dictionary) -> Dictionary:
var workflow_id: String = str(args.get(&"workflow_id", ""))
if workflow_id.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'workflow_id'"}
return {
&"ok": true,
&"workflow_id": workflow_id,
&"message": "RunningHub workflow inspection requires API configuration. This feature will be available in a future update.",
}
# =============================================================================
# customize_and_run_workflow - Stub (requires API key)
# =============================================================================
func customize_and_run_workflow(args: Dictionary) -> Dictionary:
return {
&"ok": true,
&"message": "RunningHub workflow execution requires API configuration. This feature will be available in a future update.",
}
@@ -0,0 +1 @@
uid://br1exnegw426j
+292
View File
@@ -0,0 +1,292 @@
@tool
extends Node
class_name FileTools
## File operation tools for MCP.
## Handles: list_dir, read_file, search_project, create_script
const DEFAULT_MAX_BYTES := 200_000
const DEFAULT_MAX_RESULTS := 200
const MAX_TRAVERSAL_DEPTH := 20
const _SKIP_EXTENSIONS: Dictionary = {
".import": true, ".png": true, ".jpg": true, ".jpeg": true,
".webp": true, ".svg": true, ".ogg": true, ".wav": true,
".mp3": true, ".escn": true, ".glb": true, ".gltf": true,
".uid": true,
}
var _editor_plugin: EditorPlugin = null
func set_editor_plugin(plugin: EditorPlugin) -> void:
_editor_plugin = plugin
# =============================================================================
# list_dir - List files and folders in a directory
# =============================================================================
func list_dir(args: Dictionary) -> Dictionary:
var root: String = str(args.get(&"root", "res://"))
var include_hidden: bool = bool(args.get(&"include_hidden", false))
if not root.begins_with("res://"):
root = "res://" + root
var dir := DirAccess.open(root)
if dir == null:
return {&"ok": false, &"error": "Cannot open directory: " + root}
var files: PackedStringArray = []
var folders: PackedStringArray = []
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
# Skip hidden files unless requested
if not include_hidden and name.begins_with("."):
name = dir.get_next()
continue
# Skip .uid files
if name.ends_with(".uid"):
name = dir.get_next()
continue
if dir.current_is_dir():
folders.append(name)
else:
files.append(name)
name = dir.get_next()
dir.list_dir_end()
# Sort alphabetically
files.sort()
folders.sort()
return {
&"ok": true,
&"path": root,
&"files": files,
&"folders": folders,
&"total": files.size() + folders.size()
}
# =============================================================================
# read_file - Read contents of a file
# =============================================================================
func read_file(args: Dictionary) -> Dictionary:
var path: String = str(args.get(&"path", ""))
var start_line: int = int(args.get(&"start_line", 1))
var end_line: int = int(args.get(&"end_line", 0))
var max_bytes: int = int(args.get(&"max_bytes", DEFAULT_MAX_BYTES))
if path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'path' parameter"}
if not path.begins_with("res://"):
path = "res://" + path
if not FileAccess.file_exists(path):
return {&"ok": false, &"error": "File not found: " + path}
var file := FileAccess.open(path, FileAccess.READ)
if file == null:
return {&"ok": false, &"error": "Cannot open file: " + path}
var content: String
var line_count: int = 0
# If no line range specified, read up to max_bytes
if end_line <= 0 and start_line <= 1:
var size := mini(max_bytes, file.get_length())
content = file.get_buffer(size).get_string_from_utf8()
# Count lines
line_count = content.count("\n") + 1
else:
# Read specific line range
var lines: Array = []
var current_line := 0
var total_bytes := 0
while not file.eof_reached():
var line := file.get_line()
current_line += 1
if current_line < start_line:
continue
if end_line > 0 and current_line > end_line:
break
lines.append(line)
total_bytes += line.length() + 1 # +1 for newline
if total_bytes > max_bytes:
break
content = "\n".join(lines)
line_count = lines.size()
file.close()
return {
&"ok": true,
&"path": path,
&"content": content,
&"line_count": line_count,
&"range": [start_line, end_line] if end_line > 0 else null
}
# =============================================================================
# search_project - Search for text in project files
# =============================================================================
func search_project(args: Dictionary) -> Dictionary:
var query: String = str(args.get(&"query", ""))
var glob_filter: String = str(args.get(&"glob", ""))
var max_results: int = int(args.get(&"max_results", DEFAULT_MAX_RESULTS))
var case_sensitive: bool = bool(args.get(&"case_sensitive", false))
if query.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'query' parameter"}
var search_query := query if case_sensitive else query.to_lower()
var files := _collect_files("res://", glob_filter)
var matches: Array = []
for file_path: String in files:
if matches.size() >= max_results:
break
var file := FileAccess.open(file_path, FileAccess.READ)
if file == null:
continue
var content := file.get_as_text()
file.close()
var search_content := content if case_sensitive else content.to_lower()
if search_content.find(search_query) == -1:
continue
var lines := content.split("\n")
for i: int in range(lines.size()):
var line := lines[i]
var search_line := line if case_sensitive else line.to_lower()
if search_line.find(search_query) != -1:
matches.append({
&"file": file_path,
&"line": i + 1,
&"content": line.strip_edges()
})
if matches.size() >= max_results:
break
return {
&"ok": true,
&"query": query,
&"matches": matches,
&"total_matches": matches.size(),
&"truncated": matches.size() >= max_results
}
func _collect_files(path: String, glob_filter: String) -> PackedStringArray:
"""Recursively collect all searchable files."""
var result: PackedStringArray = []
_collect_files_recursive(path, glob_filter, result)
return result
func _collect_files_recursive(path: String, glob_filter: String, out: PackedStringArray, depth: int = 0) -> void:
if depth >= MAX_TRAVERSAL_DEPTH:
return
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
# Skip hidden
if name.begins_with("."):
name = dir.get_next()
continue
var full_path := path.path_join(name)
if dir.current_is_dir():
_collect_files_recursive(full_path, glob_filter, out, depth + 1)
else:
var ext := "." + name.get_extension().to_lower()
if not _SKIP_EXTENSIONS.has(ext):
if glob_filter.is_empty() or _matches_glob(full_path, glob_filter):
out.append(full_path)
name = dir.get_next()
dir.list_dir_end()
func _matches_glob(path: String, pattern: String) -> bool:
"""Simple glob matching: *.gd, **/*.tscn, etc."""
# Handle **/*.ext pattern
if pattern.begins_with("**/"):
var ext := pattern.substr(3) # Remove **/
return path.ends_with(ext.replace("*", ""))
# Handle *.ext pattern
if pattern.begins_with("*."):
return path.ends_with(pattern.substr(1))
# Simple contains check
return path.find(pattern) != -1
# =============================================================================
# create_script - Create a new GDScript file
# =============================================================================
func create_script(args: Dictionary) -> Dictionary:
var path: String = str(args.get(&"path", ""))
var content: String = str(args.get(&"content", ""))
if path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'path' parameter"}
if not path.begins_with("res://"):
path = "res://" + path
# Add .gd extension if missing
if not "." in path.get_file():
path += ".gd"
# Check if file already exists
if FileAccess.file_exists(path):
return {&"ok": false, &"error": "File already exists: " + path}
# Ensure parent directory exists
var dir_path := path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
var err := DirAccess.make_dir_recursive_absolute(dir_path)
if err != OK:
return {&"ok": false, &"error": "Could not create directory: " + dir_path}
# Write file
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return {&"ok": false, &"error": "Could not create file: " + path}
file.store_string(content)
file.close()
# Refresh filesystem so Godot sees the new file
_refresh_filesystem()
return {
&"ok": true,
&"path": path,
&"size_bytes": content.length(),
&"message": "Script created successfully"
}
func _refresh_filesystem() -> void:
"""Tell Godot to rescan the filesystem."""
if _editor_plugin != null:
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
elif Engine.is_editor_hint():
# Fallback if no plugin reference
var editor_interface = Engine.get_singleton("EditorInterface")
if editor_interface:
editor_interface.get_resource_filesystem().scan()
+1
View File
@@ -0,0 +1 @@
uid://fqjgqcv4hnjn
+744
View File
@@ -0,0 +1,744 @@
@tool
extends Node
class_name ProjectTools
## Project configuration and debug tools for MCP.
## Handles: get_project_settings, list_settings, update_project_settings,
## get_input_map, configure_input_map, get_collision_layers,
## get_node_properties, setup_autoload,
## get_console_log, get_errors, clear_console_log,
## open_in_godot, scene_tree_dump
var _editor_plugin: EditorPlugin = null
# Cached reference to the editor Output panel's RichTextLabel.
var _editor_log_rtl: RichTextLabel = null
# Character offset for clear_console_log.
var _clear_char_offset: int = 0
func set_editor_plugin(plugin: EditorPlugin) -> void:
_editor_plugin = plugin
# =============================================================================
# get_project_settings
# =============================================================================
func get_project_settings(args: Dictionary) -> Dictionary:
var include_render: bool = bool(args.get(&"include_render", true))
var include_physics: bool = bool(args.get(&"include_physics", true))
var out: Dictionary = {}
out[&"main_scene"] = str(ProjectSettings.get_setting("application/run/main_scene", ""))
# Window size
var width = ProjectSettings.get_setting("display/window/size/viewport_width", null)
var height = ProjectSettings.get_setting("display/window/size/viewport_height", null)
if width != null: out[&"window_width"] = int(width)
if height != null: out[&"window_height"] = int(height)
# Stretch
var stretch_mode = ProjectSettings.get_setting("display/window/stretch/mode", null)
var stretch_aspect = ProjectSettings.get_setting("display/window/stretch/aspect", null)
if stretch_mode != null: out[&"stretch_mode"] = str(stretch_mode)
if stretch_aspect != null: out[&"stretch_aspect"] = str(stretch_aspect)
if include_physics:
var pps = ProjectSettings.get_setting("physics/common/physics_ticks_per_second", null)
if pps != null: out[&"physics_ticks_per_second"] = int(pps)
if include_render:
var method = ProjectSettings.get_setting("rendering/renderer/rendering_method", null)
if method != null: out[&"rendering_method"] = str(method)
var vsync = ProjectSettings.get_setting("display/window/vsync/vsync_mode", null)
if vsync != null: out[&"vsync"] = str(vsync)
return {&"ok": true, &"settings": out}
# =============================================================================
# list_settings
# =============================================================================
func list_settings(args: Dictionary) -> Dictionary:
var category: String = str(args.get(&"category", ""))
var properties: Array = ProjectSettings.get_property_list()
if category.strip_edges().is_empty():
var categories: Dictionary = {}
for prop: Dictionary in properties:
var prop_name: String = prop[&"name"]
if prop_name.is_empty() or prop_name.begins_with("_"):
continue
var slash_idx := prop_name.find("/")
if slash_idx == -1:
continue
var cat: String = prop_name.substr(0, slash_idx)
categories[cat] = categories.get(cat, 0) + 1
return {&"ok": true, &"categories": categories,
&"hint": "Pass a category name to list its settings with current values and valid options."}
var settings: Array = []
for prop: Dictionary in properties:
var prop_name: String = prop[&"name"]
if not prop_name.begins_with(category + "/"):
continue
if prop_name.begins_with("_"):
continue
var info: Dictionary = {
&"path": prop_name,
&"type": _type_to_string(prop[&"type"]),
&"value": _serialize_value(ProjectSettings.get_setting(prop_name))
}
var hint: int = prop.get(&"hint", 0)
var hint_string: String = str(prop.get(&"hint_string", ""))
if hint == PROPERTY_HINT_ENUM and not hint_string.is_empty():
info[&"enum_values"] = hint_string
elif hint == PROPERTY_HINT_RANGE and not hint_string.is_empty():
info[&"range"] = hint_string
settings.append(info)
return {&"ok": true, &"category": category, &"settings": settings, &"count": settings.size()}
# =============================================================================
# update_project_settings
# =============================================================================
func update_project_settings(args: Dictionary) -> Dictionary:
var settings = args.get(&"settings", {})
if not settings is Dictionary or settings.is_empty():
return {&"ok": false, &"error": "Missing or empty 'settings' dictionary. Use list_settings to discover available setting paths."}
var updated: Array = []
for key: String in settings:
ProjectSettings.set_setting(key, settings[key])
updated.append(key)
_save_and_refresh_settings()
return {&"ok": true, &"updated": updated, &"count": updated.size()}
# =============================================================================
# get_input_map
# =============================================================================
func get_input_map(args: Dictionary) -> Dictionary:
var include_deadzones: bool = bool(args.get(&"include_deadzones", true))
var actions: Array = InputMap.get_actions()
actions.sort()
var result: Dictionary = {}
for action: StringName in actions:
var events: Array = []
for e: InputEvent in InputMap.action_get_events(action):
var item := {&"type": e.get_class()}
if e is InputEventKey:
var keycode = e.physical_keycode if e.physical_keycode != 0 else e.keycode
item[&"keycode"] = keycode
item[&"key_label"] = OS.get_keycode_string(keycode) if keycode != 0 else ""
elif e is InputEventMouseButton:
item[&"button_index"] = e.button_index
elif e is InputEventJoypadButton:
item[&"button_index"] = e.button_index
elif e is InputEventJoypadMotion:
item[&"axis"] = e.axis
if include_deadzones:
item[&"axis_value"] = e.axis_value
events.append(item)
result[action] = events
return {&"ok": true, &"actions": result, &"count": result.size()}
# =============================================================================
# configure_input_map
# =============================================================================
func configure_input_map(args: Dictionary) -> Dictionary:
var action: String = str(args.get(&"action", ""))
var operation: String = str(args.get(&"operation", ""))
if action.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'action' name"}
if operation.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'operation'. Use: add, remove, set"}
match operation:
"add":
return _input_map_add(action, args)
"remove":
return _input_map_remove(action)
"set":
return _input_map_set(action, args)
_:
return {&"ok": false, &"error": "Unknown operation: %s. Use: add, remove, set" % operation}
func _input_map_add(action: String, args: Dictionary) -> Dictionary:
var deadzone: float = float(args.get(&"deadzone", 0.5))
var events_data: Array = args.get(&"events", [])
var created := false
if not InputMap.has_action(action):
InputMap.add_action(action, deadzone)
created = true
var added_events: Array = []
var event_errors: Array = []
for event_desc in events_data:
if not event_desc is Dictionary:
continue
var result: Dictionary = _create_input_event(event_desc)
if result.has(&"error"):
event_errors.append(result[&"error"])
continue
InputMap.action_add_event(action, result[&"event"])
added_events.append(_describe_event(result[&"event"]))
_persist_action(action)
_save_and_refresh_settings()
_try_refresh_input_map_ui()
var msg := "Action '%s' %s" % [action, "created" if created else "updated"]
if added_events.size() > 0:
msg += " with %d event(s)" % added_events.size()
var out: Dictionary = {&"ok": true, &"message": msg, &"events_added": added_events}
if event_errors.size() > 0:
out[&"event_errors"] = event_errors
return out
func _input_map_remove(action: String) -> Dictionary:
if not InputMap.has_action(action):
return {&"ok": false, &"error": "Action not found: " + action}
InputMap.erase_action(action)
if ProjectSettings.has_setting("input/" + action):
ProjectSettings.clear("input/" + action)
_save_and_refresh_settings()
_try_refresh_input_map_ui()
return {&"ok": true, &"message": "Removed action: " + action}
func _input_map_set(action: String, args: Dictionary) -> Dictionary:
var deadzone: float = float(args.get(&"deadzone", 0.5))
var events_data: Array = args.get(&"events", [])
if InputMap.has_action(action):
InputMap.erase_action(action)
InputMap.add_action(action, deadzone)
var added_events: Array = []
var event_errors: Array = []
for event_desc in events_data:
if not event_desc is Dictionary:
continue
var result: Dictionary = _create_input_event(event_desc)
if result.has(&"error"):
event_errors.append(result[&"error"])
continue
InputMap.action_add_event(action, result[&"event"])
added_events.append(_describe_event(result[&"event"]))
_persist_action(action)
_save_and_refresh_settings()
_try_refresh_input_map_ui()
var out: Dictionary = {&"ok": true, &"message": "Set action '%s' with %d event(s)" % [action, added_events.size()], &"events": added_events}
if event_errors.size() > 0:
out[&"event_errors"] = event_errors
return out
func _create_input_event(desc: Dictionary) -> Dictionary:
var type: String = str(desc.get(&"type", ""))
match type:
"key":
var key_string: String = str(desc.get(&"key", ""))
if key_string.is_empty():
return {&"error": "Missing 'key' for key event"}
var event := InputEventKey.new()
var keycode := OS.find_keycode_from_string(key_string)
if keycode == 0:
return {&"error": "Unknown key: " + key_string}
event.physical_keycode = keycode
return {&"event": event}
"mouse_button":
var button_index: int = int(desc.get(&"button_index", 0))
if button_index <= 0:
return {&"error": "Invalid 'button_index' for mouse_button (must be >= 1: 1=left, 2=right, 3=middle)"}
var event := InputEventMouseButton.new()
event.button_index = button_index
return {&"event": event}
"joypad_button":
var button_index: int = int(desc.get(&"button_index", -1))
if button_index < 0:
return {&"error": "Missing or invalid 'button_index' for joypad_button"}
var event := InputEventJoypadButton.new()
event.button_index = button_index
return {&"event": event}
"joypad_motion":
var axis: int = int(desc.get(&"axis", -1))
if axis < 0:
return {&"error": "Missing or invalid 'axis' for joypad_motion"}
var axis_value: float = float(desc.get(&"axis_value", 0.0))
var event := InputEventJoypadMotion.new()
event.axis = axis
event.axis_value = axis_value
return {&"event": event}
_:
return {&"error": "Unknown event type: '%s'. Use: key, mouse_button, joypad_button, joypad_motion" % type}
func _save_and_refresh_settings() -> void:
ProjectSettings.save()
ProjectSettings.notify_property_list_changed()
func _try_refresh_input_map_ui() -> void:
if not _editor_plugin:
return
var base := _editor_plugin.get_editor_interface().get_base_control()
var pse := _find_node_by_class(base, "ProjectSettingsEditor")
if not pse:
return
if pse.has_method("_update_action_map_editor"):
pse.call("_update_action_map_editor")
else:
push_warning("[Godot MCP] Input map changed and saved, but the editor UI could not refresh. Reopen Project Settings to see changes.")
func _persist_action(action: String) -> void:
if not InputMap.has_action(action):
return
var deadzone: float = InputMap.action_get_deadzone(action)
var events: Array = InputMap.action_get_events(action)
ProjectSettings.set_setting("input/" + action, {
"deadzone": deadzone,
"events": events
})
func _describe_event(event: InputEvent) -> String:
if event is InputEventKey:
var keycode: int = event.physical_keycode if event.physical_keycode != 0 else event.keycode
var label: String = OS.get_keycode_string(keycode) if keycode != 0 else "Unknown"
return "Key: " + label
elif event is InputEventMouseButton:
return "Mouse Button: " + str(event.button_index)
elif event is InputEventJoypadButton:
return "Joypad Button: " + str(event.button_index)
elif event is InputEventJoypadMotion:
return "Joypad Axis: %d (%.1f)" % [event.axis, event.axis_value]
return event.get_class()
# =============================================================================
# get_collision_layers
# =============================================================================
func get_collision_layers(_args: Dictionary) -> Dictionary:
var layers_2d: Array = _collect_layers("layer_names/2d_physics")
var layers_3d: Array = _collect_layers("layer_names/3d_physics")
return {&"ok": true, &"layers_2d": layers_2d, &"layers_3d": layers_3d}
func _collect_layers(prefix: String) -> Array:
var out: Array = []
for i: int in range(1, 33):
var key := "%s/layer_%d" % [prefix, i]
var layer_name := str(ProjectSettings.get_setting(key, ""))
if not layer_name.is_empty():
out.append({&"index": i, &"name": layer_name})
return out
# =============================================================================
# get_node_properties
# =============================================================================
const _SKIP_PROPS: Dictionary = {
"script": true, "owner": true, "scene_file_path": true, "unique_name_in_owner": true,
}
const ENUM_HINTS = {
"anchors_preset": "0:Top Left,1:Top Right,2:Bottom Right,3:Bottom Left,4:Center Left,5:Center Top,6:Center Right,7:Center Bottom,8:Center,9:Left Wide,10:Top Wide,11:Right Wide,12:Bottom Wide,13:VCenter Wide,14:HCenter Wide,15:Full Rect",
"grow_horizontal": "0:Begin,1:End,2:Both",
"grow_vertical": "0:Begin,1:End,2:Both",
"horizontal_alignment": "0:Left,1:Center,2:Right,3:Fill",
"vertical_alignment": "0:Top,1:Center,2:Bottom,3:Fill"
}
func get_node_properties(args: Dictionary) -> Dictionary:
var node_type: String = str(args.get(&"node_type", ""))
if node_type.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'node_type'"}
if not ClassDB.class_exists(node_type):
return {&"ok": false, &"error": "Unknown node type: " + node_type}
var temp = ClassDB.instantiate(node_type)
if not temp:
return {&"ok": false, &"error": "Cannot instantiate: " + node_type}
var properties: Array = []
for prop: Dictionary in temp.get_property_list():
var prop_name: String = prop[&"name"]
if prop_name.begins_with("_"):
continue
if _SKIP_PROPS.has(prop_name):
continue
if not (prop.get(&"usage", 0) & PROPERTY_USAGE_EDITOR):
continue
var info := {
&"name": prop_name,
&"type": _type_to_string(prop[&"type"]),
&"default": _serialize_value(temp.get(prop_name))
}
# Enum hints
if prop.has(&"hint") and prop[&"hint"] == PROPERTY_HINT_ENUM and prop.has(&"hint_string"):
info[&"enum_values"] = prop[&"hint_string"]
if prop_name in ENUM_HINTS:
info[&"enum_values"] = ENUM_HINTS[prop_name]
properties.append(info)
temp.queue_free()
# Inheritance chain
var chain: Array = []
var cls: String = node_type
while cls != "":
chain.append(cls)
cls = ClassDB.get_parent_class(cls)
return {&"ok": true, &"node_type": node_type, &"inheritance_chain": chain,
&"property_count": properties.size(), &"properties": properties}
func _type_to_string(type_id: int) -> String:
match type_id:
TYPE_BOOL: return "bool"
TYPE_INT: return "int"
TYPE_FLOAT: return "float"
TYPE_STRING: return "String"
TYPE_VECTOR2: return "Vector2"
TYPE_VECTOR3: return "Vector3"
TYPE_VECTOR2I: return "Vector2i"
TYPE_VECTOR3I: return "Vector3i"
TYPE_COLOR: return "Color"
TYPE_RECT2: return "Rect2"
TYPE_OBJECT: return "Resource"
TYPE_ARRAY: return "Array"
TYPE_DICTIONARY: return "Dictionary"
_: return "Variant"
func _serialize_value(value: Variant) -> Variant:
match typeof(value):
TYPE_VECTOR2: return {&"type": &"Vector2", &"x": value.x, &"y": value.y}
TYPE_VECTOR3: return {&"type": &"Vector3", &"x": value.x, &"y": value.y, &"z": value.z}
TYPE_COLOR: return {&"type": &"Color", &"r": value.r, &"g": value.g, &"b": value.b, &"a": value.a}
TYPE_VECTOR2I: return {&"type": &"Vector2i", &"x": value.x, &"y": value.y}
TYPE_VECTOR3I: return {&"type": &"Vector3i", &"x": value.x, &"y": value.y, &"z": value.z}
TYPE_OBJECT:
if value and value is Resource and value.resource_path:
return {&"type": &"Resource", &"path": value.resource_path}
return null
_: return value
# =============================================================================
# setup_autoload
# =============================================================================
func setup_autoload(args: Dictionary) -> Dictionary:
var operation: String = str(args.get(&"operation", ""))
if operation.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'operation'. Use: add, remove, list"}
match operation:
"list":
return _autoload_list()
"add":
return _autoload_add(args)
"remove":
return _autoload_remove(args)
_:
return {&"ok": false, &"error": "Unknown operation: %s. Use: add, remove, list" % operation}
func _autoload_list() -> Dictionary:
var autoloads: Array = []
for prop: Dictionary in ProjectSettings.get_property_list():
var prop_name: String = prop[&"name"]
if not prop_name.begins_with("autoload/"):
continue
var al_name: String = prop_name.substr(9)
var al_path: String = str(ProjectSettings.get_setting(prop_name, ""))
var enabled: bool = al_path.begins_with("*")
if enabled:
al_path = al_path.substr(1)
autoloads.append({&"name": al_name, &"path": al_path, &"enabled": enabled})
return {&"ok": true, &"autoloads": autoloads, &"count": autoloads.size()}
func _autoload_add(args: Dictionary) -> Dictionary:
var autoload_name: String = str(args.get(&"name", ""))
var path: String = str(args.get(&"path", ""))
if autoload_name.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'name'"}
if path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'path' for add operation"}
if not path.begins_with("res://"):
path = "res://" + path
if not FileAccess.file_exists(path):
return {&"ok": false, &"error": "File not found: " + path}
var setting_key := "autoload/" + autoload_name
ProjectSettings.set_setting(setting_key, "*" + path)
_save_and_refresh_settings()
return {&"ok": true, &"message": "Registered autoload: %s -> %s" % [autoload_name, path]}
func _autoload_remove(args: Dictionary) -> Dictionary:
var autoload_name: String = str(args.get(&"name", ""))
if autoload_name.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'name'"}
var setting_key := "autoload/" + autoload_name
if not ProjectSettings.has_setting(setting_key):
return {&"ok": false, &"error": "Autoload not found: " + autoload_name}
ProjectSettings.clear(setting_key)
_save_and_refresh_settings()
return {&"ok": true, &"message": "Unregistered autoload: " + autoload_name}
# =============================================================================
# Editor Output Panel access
# =============================================================================
# We read directly from the editor's internal EditorLog RichTextLabel.
# This is real-time and matches exactly what the user sees in the Output panel.
# =============================================================================
func _get_editor_log_rtl() -> RichTextLabel:
"""Find (and cache) the RichTextLabel inside the editor's Output panel."""
if is_instance_valid(_editor_log_rtl):
return _editor_log_rtl
if not _editor_plugin:
return null
var base := _editor_plugin.get_editor_interface().get_base_control()
var editor_log := _find_node_by_class(base, "EditorLog")
if editor_log:
_editor_log_rtl = _find_child_rtl(editor_log)
return _editor_log_rtl
func _find_node_by_class(root: Node, cls_name: String) -> Node:
if root.get_class() == cls_name:
return root
for child: Node in root.get_children():
var found := _find_node_by_class(child, cls_name)
if found:
return found
return null
func _find_child_rtl(node: Node) -> RichTextLabel:
for child: Node in node.get_children():
if child is RichTextLabel:
return child
var found := _find_child_rtl(child)
if found:
return found
return null
func _read_output_panel_lines() -> Array:
"""Return all non-empty lines from the editor Output panel (after clear offset)."""
var rtl := _get_editor_log_rtl()
if not rtl:
return []
var full_text: String = rtl.get_parsed_text()
if _clear_char_offset > 0 and _clear_char_offset < full_text.length():
full_text = full_text.substr(_clear_char_offset)
elif _clear_char_offset >= full_text.length():
return []
var lines: Array = []
for line: String in full_text.split("\n"):
if not line.strip_edges().is_empty():
lines.append(line)
return lines
# =============================================================================
# get_console_log
# =============================================================================
func get_console_log(args: Dictionary) -> Dictionary:
var max_lines: int = int(args.get(&"max_lines", 50))
var rtl := _get_editor_log_rtl()
if not rtl:
return {&"ok": false,
&"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."}
var all_lines := _read_output_panel_lines()
var start := maxi(0, all_lines.size() - max_lines)
var lines := all_lines.slice(start)
return {&"ok": true, &"lines": lines, &"line_count": lines.size(),
&"content": "\n".join(lines)}
# =============================================================================
# get_errors
# =============================================================================
const _ERROR_PREFIXES: PackedStringArray = [
"ERROR:", "SCRIPT ERROR:", "USER ERROR:",
"WARNING:", "USER WARNING:", "SCRIPT WARNING:",
"Parse Error:", "Invalid",
]
func get_errors(args: Dictionary) -> Dictionary:
var max_errors: int = int(args.get(&"max_errors", 50))
var include_warnings: bool = bool(args.get(&"include_warnings", true))
var rtl := _get_editor_log_rtl()
if not rtl:
return {&"ok": false,
&"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."}
var all_lines := _read_output_panel_lines()
var all_errors: Array = []
for i: int in range(all_lines.size()):
var line: String = all_lines[i].strip_edges()
if line.is_empty():
continue
var is_error := false
var severity := "error"
for prefix: String in _ERROR_PREFIXES:
if line.begins_with(prefix):
is_error = true
if "WARNING" in prefix:
severity = "warning"
break
# Godot continuation lines: "at: res://path/file.gd:123"
if not is_error and line.begins_with("at: ") and "res://" in line:
if all_errors.size() > 0:
var prev: Dictionary = all_errors[all_errors.size() - 1]
var loc := _extract_file_line(line)
if not loc.is_empty():
prev[&"file"] = loc.get(&"file", "")
prev[&"line"] = loc.get(&"line", 0)
continue
if not is_error:
continue
if severity == "warning" and not include_warnings:
continue
var error_info := {&"message": line, &"severity": severity}
var loc := _extract_file_line(line)
if not loc.is_empty():
error_info[&"file"] = loc.get(&"file", "")
error_info[&"line"] = loc.get(&"line", 0)
all_errors.append(error_info)
# Return the most recent errors
var start := maxi(0, all_errors.size() - max_errors)
var errors := all_errors.slice(start)
return {&"ok": true, &"errors": errors, &"error_count": errors.size(),
&"summary": "%d error(s) found" % errors.size()}
func _extract_file_line(text: String) -> Dictionary:
var idx := text.find("res://")
if idx == -1:
return {}
var rest := text.substr(idx)
var colon_idx := rest.find(":", 6)
if colon_idx == -1:
return {&"file": rest.strip_edges()}
var file_path := rest.substr(0, colon_idx)
var after_colon := rest.substr(colon_idx + 1)
var line_str := ""
for c in after_colon:
if c.is_valid_int():
line_str += c
else:
break
if not line_str.is_empty():
return {&"file": file_path, &"line": int(line_str)}
return {&"file": file_path}
# =============================================================================
# clear_console_log
# =============================================================================
func clear_console_log(_args: Dictionary) -> Dictionary:
var rtl := _get_editor_log_rtl()
if not rtl:
return {&"ok": false,
&"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."}
# Actually clear the editor Output panel
rtl.clear()
_clear_char_offset = 0
return {&"ok": true,
&"message": "Console log cleared."}
# =============================================================================
# open_in_godot
# =============================================================================
func open_in_godot(args: Dictionary) -> Dictionary:
var path: String = str(args.get(&"path", ""))
var line: int = int(args.get(&"line", 0))
if path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'path'"}
if not path.begins_with("res://"):
path = "res://" + path
if not _editor_plugin:
return {&"ok": false, &"error": "Editor plugin not available"}
var ei = _editor_plugin.get_editor_interface()
if path.ends_with(".gd") or path.ends_with(".shader"):
var script = load(path)
if script:
ei.edit_resource(script)
if line > 0:
ei.get_script_editor().goto_line(line - 1)
else:
return {&"ok": false, &"error": "Could not load: " + path}
elif path.ends_with(".tscn") or path.ends_with(".scn"):
ei.open_scene_from_path(path)
else:
var res = load(path)
if res:
ei.edit_resource(res)
return {&"ok": true, &"message": "Opened %s%s" % [path, " at line %d" % line if line > 0 else ""]}
# =============================================================================
# scene_tree_dump
# =============================================================================
func scene_tree_dump(_args: Dictionary) -> Dictionary:
if not _editor_plugin:
return {&"ok": false, &"error": "Editor plugin not available"}
var ei = _editor_plugin.get_editor_interface()
var edited_scene = ei.get_edited_scene_root()
if not edited_scene:
return {&"ok": true, &"tree": "(no scene open)", &"message": "No scene is currently open in the editor"}
var tree_text := _dump_node(edited_scene, 0)
return {&"ok": true, &"tree": tree_text, &"scene_path": edited_scene.scene_file_path}
func _dump_node(node: Node, depth: int) -> String:
var indent := " ".repeat(depth)
var line := "%s%s (%s)" % [indent, node.name, node.get_class()]
var script = node.get_script()
if script:
line += " [%s]" % script.resource_path.get_file()
var children := node.get_children()
if children.is_empty():
return line
var parts: PackedStringArray = [line]
for child: Node in children:
parts.append(_dump_node(child, depth + 1))
return "\n".join(parts)
@@ -0,0 +1 @@
uid://dig5fifwwanvf
+979
View File
@@ -0,0 +1,979 @@
@tool
extends Node
class_name SceneTools
## Scene operation tools for MCP.
## Handles: create_scene, read_scene, add_node, remove_node, modify_node_property,
## rename_node, move_node, attach_script, detach_script, set_collision_shape,
## set_sprite_texture
const _SKIP_PROPS: Dictionary[String, bool] = {
"script": true, "owner": true, "scene_file_path": true,
"unique_name_in_owner": true, "editor_description": true,
}
var _editor_plugin: EditorPlugin = null
func set_editor_plugin(plugin: EditorPlugin) -> void:
_editor_plugin = plugin
# =============================================================================
# Shared helpers
# =============================================================================
func _refresh_and_reload(scene_path: String) -> void:
_refresh_filesystem()
_reload_scene_in_editor(scene_path)
func _refresh_filesystem() -> void:
if _editor_plugin:
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
func _reload_scene_in_editor(scene_path: String) -> void:
if not _editor_plugin:
return
var ei = _editor_plugin.get_editor_interface()
var edited = ei.get_edited_scene_root()
if edited and edited.scene_file_path == scene_path:
ei.reload_scene_from_path(scene_path)
func _ensure_res_path(path: String) -> String:
if not path.begins_with("res://"):
return "res://" + path
return path
func _load_scene(scene_path: String) -> Array:
"""Returns [scene_root, error_dict]. If error_dict is not empty, scene_root is null."""
if not FileAccess.file_exists(scene_path):
return [null, {&"ok": false, &"error": "Scene does not exist: " + scene_path}]
var packed = load(scene_path) as PackedScene
if not packed:
return [null, {&"ok": false, &"error": "Failed to load scene: " + scene_path}]
var root = packed.instantiate()
if not root:
return [null, {&"ok": false, &"error": "Failed to instantiate scene"}]
return [root, {}]
func _save_scene(scene_root: Node, scene_path: String) -> Dictionary:
"""Pack and save a scene. Returns error dict or empty on success."""
var packed = PackedScene.new()
var pack_result = packed.pack(scene_root)
if pack_result != OK:
scene_root.queue_free()
return {&"ok": false, &"error": "Failed to pack scene: " + str(pack_result)}
var save_result = ResourceSaver.save(packed, scene_path)
scene_root.queue_free()
if save_result != OK:
return {&"ok": false, &"error": "Failed to save scene: " + str(save_result)}
_refresh_and_reload(scene_path)
return {}
func _find_node(scene_root: Node, node_path: String) -> Node:
if node_path == "." or node_path.is_empty():
return scene_root
return scene_root.get_node_or_null(node_path)
func _parse_value(value: Variant) -> Variant:
"""Convert dictionary-encoded types to Godot types."""
if value is Dictionary:
var t: String = value.get(&"type", "")
match t:
"Vector2": return Vector2(value.get(&"x", 0), value.get(&"y", 0))
"Vector3": return Vector3(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
"Color": return Color(value.get(&"r", 1), value.get(&"g", 1), value.get(&"b", 1), value.get(&"a", 1))
"Vector2i": return Vector2i(value.get(&"x", 0), value.get(&"y", 0))
"Vector3i": return Vector3i(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
"Rect2": return Rect2(value.get(&"x", 0), value.get(&"y", 0), value.get(&"width", 0), value.get(&"height", 0))
return value
func _set_node_properties(node: Node, properties: Dictionary) -> void:
for prop_name: String in properties:
var prop_value = _parse_value(properties[prop_name])
node.set(prop_name, prop_value)
func _serialize_value(value: Variant) -> Variant:
match typeof(value):
TYPE_VECTOR2: return {&"type": &"Vector2", &"x": value.x, &"y": value.y}
TYPE_VECTOR3: return {&"type": &"Vector3", &"x": value.x, &"y": value.y, &"z": value.z}
TYPE_COLOR: return {&"type": &"Color", &"r": value.r, &"g": value.g, &"b": value.b, &"a": value.a}
TYPE_VECTOR2I: return {&"type": &"Vector2i", &"x": value.x, &"y": value.y}
TYPE_VECTOR3I: return {&"type": &"Vector3i", &"x": value.x, &"y": value.y, &"z": value.z}
TYPE_RECT2: return {&"type": &"Rect2", &"x": value.position.x, &"y": value.position.y, &"width": value.size.x, &"height": value.size.y}
TYPE_OBJECT:
if value and value is Resource and value.resource_path:
return {&"type": &"Resource", &"path": value.resource_path}
return null
_: return value
# =============================================================================
# create_scene
# =============================================================================
func create_scene(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var root_node_name: String = str(args.get(&"root_node_name", "Node"))
var root_node_type: String = str(args.get(&"root_node_type", ""))
var nodes: Array = args.get(&"nodes", [])
var attach_script_path: String = str(args.get(&"attach_script", ""))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path' parameter"}
if root_node_type.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'root_node_type' parameter"}
if not scene_path.ends_with(".tscn"):
scene_path += ".tscn"
if FileAccess.file_exists(scene_path):
return {&"ok": false, &"error": "Scene already exists: " + scene_path}
if not ClassDB.class_exists(root_node_type):
return {&"ok": false, &"error": "Invalid root node type: " + root_node_type}
# Ensure parent directory
var dir_path := scene_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var root: Node = ClassDB.instantiate(root_node_type) as Node
if not root:
return {&"ok": false, &"error": "Failed to create root node of type: " + root_node_type}
root.name = root_node_name
if not attach_script_path.is_empty():
var script_res = load(attach_script_path)
if script_res:
root.set_script(script_res)
var node_count := 0
for node_data: Variant in nodes:
if typeof(node_data) == TYPE_DICTIONARY:
var created = _create_node_recursive(node_data, root, root)
if created:
node_count += _count_nodes(created)
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"path": scene_path, &"root_type": root_node_type, &"child_count": node_count,
&"message": "Scene created at " + scene_path}
func _create_node_recursive(data: Dictionary, parent: Node, owner: Node) -> Node:
var n_name: String = str(data.get(&"name", "Node"))
var n_type: String = str(data.get(&"type", "Node"))
var n_script: String = str(data.get(&"script", ""))
var props: Dictionary = data.get(&"properties", {})
var children: Array = data.get(&"children", [])
if not ClassDB.class_exists(n_type):
return null
var node: Node = ClassDB.instantiate(n_type) as Node
if not node:
return null
node.name = n_name
_set_node_properties(node, props)
if not n_script.is_empty():
var s = load(n_script)
if s:
node.set_script(s)
parent.add_child(node)
node.owner = owner
for child_data: Variant in children:
if typeof(child_data) == TYPE_DICTIONARY:
_create_node_recursive(child_data, node, owner)
return node
func _count_nodes(node: Node) -> int:
var count := 1
for child: Node in node.get_children():
count += _count_nodes(child)
return count
# =============================================================================
# read_scene
# =============================================================================
func read_scene(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var include_properties: bool = args.get(&"include_properties", false)
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path' parameter"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var structure = _build_node_structure(root, include_properties)
root.queue_free()
return {&"ok": true, &"scene_path": scene_path, &"root": structure}
func _build_node_structure(node: Node, include_props: bool, path: String = ".") -> Dictionary:
const PROPERTIES: PackedStringArray = ["position", "rotation", "scale", "size", "offset", "visible",
"modulate", "z_index", "text", "collision_layer", "collision_mask", "mass"]
var data := {&"name": str(node.name), &"type": node.get_class(), &"path": path, &"children": []}
var script = node.get_script()
if script:
data[&"script"] = script.resource_path
if include_props:
var props := {}
for prop_name: String in PROPERTIES:
var val = node.get(prop_name)
if val != null:
props[prop_name] = _serialize_value(val)
if not props.is_empty():
data[&"properties"] = props
for child: Node in node.get_children():
var child_path = child.name if path == "." else path + "/" + child.name
data[&"children"].append(_build_node_structure(child, include_props, child_path))
return data
# =============================================================================
# add_node
# =============================================================================
func add_node(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_name: String = str(args.get(&"node_name", ""))
var node_type: String = str(args.get(&"node_type", "Node"))
var parent_path: String = str(args.get(&"parent_path", "."))
var properties: Dictionary = args.get(&"properties", {})
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if node_name.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'node_name'"}
if not ClassDB.class_exists(node_type):
return {&"ok": false, &"error": "Invalid node type: " + node_type}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var parent = _find_node(root, parent_path)
if not parent:
root.queue_free()
return {&"ok": false, &"error": "Parent node not found: " + parent_path}
var new_node: Node = ClassDB.instantiate(node_type) as Node
if not new_node:
root.queue_free()
return {&"ok": false, &"error": "Failed to create node of type: " + node_type}
new_node.name = node_name
_set_node_properties(new_node, properties)
parent.add_child(new_node)
new_node.owner = root
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"scene_path": scene_path, &"node_name": node_name, &"node_type": node_type,
&"message": "Added %s (%s) to scene" % [node_name, node_type]}
# =============================================================================
# remove_node
# =============================================================================
func remove_node(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", ""))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if node_path.strip_edges().is_empty() or node_path == ".":
return {&"ok": false, &"error": "Cannot remove root node"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = root.get_node_or_null(node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
var n_name = target.name
var n_type = target.get_class()
target.get_parent().remove_child(target)
target.queue_free()
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"scene_path": scene_path, &"removed_node": node_path,
&"message": "Removed %s (%s)" % [n_name, n_type]}
# =============================================================================
# modify_node_property
# =============================================================================
func modify_node_property(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", "."))
var property_name: String = str(args.get(&"property_name", ""))
var value = args.get(&"value")
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if property_name.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'property_name'"}
if value == null:
return {&"ok": false, &"error": "Missing 'value'"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = _find_node(root, node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
# Check property exists
var prop_exists := false
for prop: Dictionary in target.get_property_list():
if prop[&"name"] == property_name:
prop_exists = true
break
if not prop_exists:
var node_type = target.get_class()
root.queue_free()
return {&"ok": false, &"error": "Property '%s' not found on %s (%s). Use get_node_properties to discover available properties." % [property_name, node_path, node_type]}
var parsed = _parse_value(value)
var old_value = target.get(property_name)
# Validate resource type compatibility
if old_value is Resource and not (parsed is Resource):
root.queue_free()
return {&"ok": false, &"error": "Property '%s' expects a Resource. Use specialized tools (set_collision_shape, set_sprite_texture) instead." % property_name}
target.set(property_name, parsed)
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"scene_path": scene_path, &"node_path": node_path,
&"property_name": property_name, &"old_value": str(old_value), &"new_value": str(parsed),
&"message": "Set %s.%s = %s" % [node_path, property_name, str(parsed)]}
# =============================================================================
# rename_node
# =============================================================================
func rename_node(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", ""))
var new_name: String = str(args.get(&"new_name", ""))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if node_path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'node_path'"}
if new_name.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'new_name'"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = _find_node(root, node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
var old_name = target.name
target.name = new_name
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"old_name": str(old_name), &"new_name": new_name,
&"message": "Renamed '%s' to '%s'" % [old_name, new_name]}
# =============================================================================
# move_node
# =============================================================================
func move_node(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", ""))
var new_parent_path: String = str(args.get(&"new_parent_path", "."))
var sibling_index: int = int(args.get(&"sibling_index", -1))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if node_path.strip_edges().is_empty() or node_path == ".":
return {&"ok": false, &"error": "Cannot move root node"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = root.get_node_or_null(node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
var new_parent = _find_node(root, new_parent_path)
if not new_parent:
root.queue_free()
return {&"ok": false, &"error": "New parent not found: " + new_parent_path}
target.get_parent().remove_child(target)
new_parent.add_child(target)
target.owner = root
if sibling_index >= 0:
new_parent.move_child(target, mini(sibling_index, new_parent.get_child_count() - 1))
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"message": "Moved '%s' to '%s'" % [node_path, new_parent_path]}
# =============================================================================
# duplicate_node
# =============================================================================
func duplicate_node(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", ""))
var new_name: String = str(args.get(&"new_name", ""))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if node_path.strip_edges().is_empty() or node_path == ".":
return {&"ok": false, &"error": "Cannot duplicate root node"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = root.get_node_or_null(node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
var parent = target.get_parent()
if not parent:
root.queue_free()
return {&"ok": false, &"error": "Cannot duplicate - no parent"}
var duplicate = target.duplicate()
if new_name.is_empty():
var base_name = target.name
var counter = 2
new_name = base_name + str(counter)
while parent.has_node(NodePath(new_name)):
counter += 1
new_name = base_name + str(counter)
duplicate.name = new_name
parent.add_child(duplicate)
_set_owner_recursive(duplicate, root)
var original_index = target.get_index()
parent.move_child(duplicate, original_index + 1)
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"new_name": new_name,
&"message": "Duplicated '%s' as '%s'" % [node_path, new_name]}
func _set_owner_recursive(node: Node, owner: Node) -> void:
node.owner = owner
for child: Node in node.get_children():
_set_owner_recursive(child, owner)
# =============================================================================
# reorder_node - simpler function just for changing sibling order
# =============================================================================
func reorder_node(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", ""))
var new_index: int = int(args.get(&"new_index", -1))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if node_path.strip_edges().is_empty() or node_path == ".":
return {&"ok": false, &"error": "Cannot reorder root node"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = root.get_node_or_null(node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
var parent = target.get_parent()
if not parent:
root.queue_free()
return {&"ok": false, &"error": "Cannot reorder - no parent"}
var old_index = target.get_index()
var max_index = parent.get_child_count() - 1
new_index = clampi(new_index, 0, max_index)
if old_index == new_index:
root.queue_free()
return {&"ok": true, &"message": "No change needed"}
parent.move_child(target, new_index)
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"old_index": old_index, &"new_index": new_index,
&"message": "Moved '%s' from index %d to %d" % [node_path, old_index, new_index]}
# =============================================================================
# attach_script
# =============================================================================
func attach_script(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", "."))
var script_path: String = str(args.get(&"script_path", ""))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if script_path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'script_path'"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = _find_node(root, node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
var script_res = load(script_path)
if not script_res:
root.queue_free()
return {&"ok": false, &"error": "Failed to load script: " + script_path}
target.set_script(script_res)
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"message": "Attached %s to node '%s'" % [script_path, node_path]}
# =============================================================================
# detach_script
# =============================================================================
func detach_script(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", "."))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = _find_node(root, node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
target.set_script(null)
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"message": "Detached script from node '%s'" % node_path}
# =============================================================================
# set_collision_shape
# =============================================================================
func set_collision_shape(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", "."))
var shape_type: String = str(args.get(&"shape_type", ""))
var shape_params: Dictionary = args.get(&"shape_params", {})
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if shape_type.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'shape_type'"}
if not ClassDB.class_exists(shape_type):
return {&"ok": false, &"error": "Invalid shape type: " + shape_type}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = _find_node(root, node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
var shape = ClassDB.instantiate(shape_type)
if not shape:
root.queue_free()
return {&"ok": false, &"error": "Failed to create shape: " + shape_type}
if shape_params.has(&"radius"):
shape.set("radius", float(shape_params[&"radius"]))
if shape_params.has(&"height"):
shape.set("height", float(shape_params[&"height"]))
if shape_params.has(&"size"):
var size_data = shape_params[&"size"]
if typeof(size_data) == TYPE_DICTIONARY:
if size_data.has(&"z"):
shape.set("size", Vector3(size_data.get(&"x", 1), size_data.get(&"y", 1), size_data.get(&"z", 1)))
else:
shape.set("size", Vector2(size_data.get(&"x", 1), size_data.get(&"y", 1)))
target.set("shape", shape)
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"message": "Set %s on node '%s'" % [shape_type, node_path]}
# =============================================================================
# set_sprite_texture
# =============================================================================
func set_sprite_texture(args: Dictionary) -> Dictionary:
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", "."))
var texture_type: String = str(args.get(&"texture_type", ""))
var texture_params: Dictionary = args.get(&"texture_params", {})
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if texture_type.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'texture_type'"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = _find_node(root, node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
var texture: Texture2D = null
match texture_type:
"ImageTexture":
var tex_path: String = str(texture_params.get(&"path", ""))
if tex_path.is_empty():
root.queue_free()
return {&"ok": false, &"error": "Missing 'path' in texture_params for ImageTexture"}
texture = load(tex_path)
if not texture:
root.queue_free()
return {&"ok": false, &"error": "Failed to load texture: " + tex_path}
"PlaceholderTexture2D":
texture = PlaceholderTexture2D.new()
var size_data = texture_params.get(&"size", {&"x": 64, &"y": 64})
if typeof(size_data) == TYPE_DICTIONARY:
texture.size = Vector2(size_data.get(&"x", 64), size_data.get(&"y", 64))
"GradientTexture2D":
texture = GradientTexture2D.new()
texture.width = int(texture_params.get(&"width", 64))
texture.height = int(texture_params.get(&"height", 64))
"NoiseTexture2D":
texture = NoiseTexture2D.new()
texture.width = int(texture_params.get(&"width", 64))
texture.height = int(texture_params.get(&"height", 64))
_:
root.queue_free()
return {&"ok": false, &"error": "Unknown texture type: " + texture_type}
target.set("texture", texture)
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {&"ok": true, &"message": "Set %s texture on node '%s'" % [texture_type, node_path]}
# =============================================================================
# get_scene_hierarchy (for visualizer)
# =============================================================================
func get_scene_hierarchy(args: Dictionary) -> Dictionary:
"""Get the full scene hierarchy with node information for the visualizer."""
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var hierarchy = _build_hierarchy_recursive(root, ".")
root.queue_free()
return {&"ok": true, &"scene_path": scene_path, &"hierarchy": hierarchy}
func _build_hierarchy_recursive(node: Node, path: String) -> Dictionary:
"""Build node hierarchy with all info needed for visualizer."""
var data := {
&"name": str(node.name),
&"type": node.get_class(),
&"path": path,
&"children": [],
&"child_count": node.get_child_count()
}
var script = node.get_script()
if script:
data[&"script"] = script.resource_path
var parent = node.get_parent()
if parent:
data[&"index"] = node.get_index()
for i: int in range(node.get_child_count()):
var child = node.get_child(i)
var child_path = child.name if path == "." else path + "/" + child.name
data[&"children"].append(_build_hierarchy_recursive(child, child_path))
return data
# =============================================================================
# get_scene_node_properties (dynamic property fetching)
# =============================================================================
func get_scene_node_properties(args: Dictionary) -> Dictionary:
"""Get all properties of a specific node in a scene with their current values."""
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", "."))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = _find_node(root, node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
var node_type = target.get_class()
var properties: Array = []
var categories: Dictionary = {}
for prop: Dictionary in target.get_property_list():
var prop_name: String = prop[&"name"]
if prop_name.begins_with("_"):
continue
if _SKIP_PROPS.has(prop_name):
continue
var usage = prop.get(&"usage", 0)
if not (usage & PROPERTY_USAGE_EDITOR):
continue
var current_value = target.get(prop_name)
var prop_info := {
&"name": prop_name,
&"type": prop[&"type"],
&"type_name": _type_id_to_name(prop[&"type"]),
&"hint": prop.get(&"hint", 0),
&"hint_string": prop.get(&"hint_string", ""),
&"value": _serialize_value(current_value),
&"usage": usage
}
var category = _get_property_category(target, prop_name)
prop_info[&"category"] = category
if not categories.has(category):
categories[category] = []
categories[category].append(prop_info)
properties.append(prop_info)
var chain: Array = []
var cls: String = node_type
while cls != "":
chain.append(cls)
cls = ClassDB.get_parent_class(cls)
root.queue_free()
return {
&"ok": true,
&"scene_path": scene_path,
&"node_path": node_path,
&"node_type": node_type,
&"node_name": target.name,
&"inheritance_chain": chain,
&"properties": properties,
&"categories": categories,
&"property_count": properties.size()
}
func _type_id_to_name(type_id: int) -> String:
"""Convert Godot type ID to human-readable name."""
match type_id:
TYPE_NIL: return "null"
TYPE_BOOL: return "bool"
TYPE_INT: return "int"
TYPE_FLOAT: return "float"
TYPE_STRING: return "String"
TYPE_VECTOR2: return "Vector2"
TYPE_VECTOR2I: return "Vector2i"
TYPE_RECT2: return "Rect2"
TYPE_RECT2I: return "Rect2i"
TYPE_VECTOR3: return "Vector3"
TYPE_VECTOR3I: return "Vector3i"
TYPE_TRANSFORM2D: return "Transform2D"
TYPE_VECTOR4: return "Vector4"
TYPE_VECTOR4I: return "Vector4i"
TYPE_PLANE: return "Plane"
TYPE_QUATERNION: return "Quaternion"
TYPE_AABB: return "AABB"
TYPE_BASIS: return "Basis"
TYPE_TRANSFORM3D: return "Transform3D"
TYPE_PROJECTION: return "Projection"
TYPE_COLOR: return "Color"
TYPE_STRING_NAME: return "StringName"
TYPE_NODE_PATH: return "NodePath"
TYPE_RID: return "RID"
TYPE_OBJECT: return "Object"
TYPE_CALLABLE: return "Callable"
TYPE_SIGNAL: return "Signal"
TYPE_DICTIONARY: return "Dictionary"
TYPE_ARRAY: return "Array"
TYPE_PACKED_BYTE_ARRAY: return "PackedByteArray"
TYPE_PACKED_INT32_ARRAY: return "PackedInt32Array"
TYPE_PACKED_INT64_ARRAY: return "PackedInt64Array"
TYPE_PACKED_FLOAT32_ARRAY: return "PackedFloat32Array"
TYPE_PACKED_FLOAT64_ARRAY: return "PackedFloat64Array"
TYPE_PACKED_STRING_ARRAY: return "PackedStringArray"
TYPE_PACKED_VECTOR2_ARRAY: return "PackedVector2Array"
TYPE_PACKED_VECTOR3_ARRAY: return "PackedVector3Array"
TYPE_PACKED_COLOR_ARRAY: return "PackedColorArray"
_: return "Variant"
func _get_property_category(node: Node, prop_name: String) -> String:
"""Determine which class in the hierarchy defines this property."""
var cls: String = node.get_class()
while cls != "":
var class_props = ClassDB.class_get_property_list(cls, true)
for prop: Dictionary in class_props:
if prop[&"name"] == prop_name:
return cls
cls = ClassDB.get_parent_class(cls)
return node.get_class()
# =============================================================================
# set_scene_node_property (for visualizer inline editing)
# =============================================================================
func set_scene_node_property(args: Dictionary) -> Dictionary:
"""Set a property on a node in a scene (supports complex types)."""
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
var node_path: String = str(args.get(&"node_path", "."))
var property_name: String = str(args.get(&"property_name", ""))
var value = args.get(&"value")
var value_type: int = int(args.get(&"value_type", -1))
if scene_path.strip_edges() == "res://":
return {&"ok": false, &"error": "Missing 'scene_path'"}
if property_name.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'property_name'"}
var result := _load_scene(scene_path)
if not result[1].is_empty():
return result[1]
var root: Node = result[0]
var target = _find_node(root, node_path)
if not target:
root.queue_free()
return {&"ok": false, &"error": "Node not found: " + node_path}
var parsed_value = _parse_typed_value(value, value_type)
var old_value = target.get(property_name)
target.set(property_name, parsed_value)
var err := _save_scene(root, scene_path)
if not err.is_empty():
return err
return {
&"ok": true,
&"scene_path": scene_path,
&"node_path": node_path,
&"property_name": property_name,
&"old_value": _serialize_value(old_value),
&"new_value": _serialize_value(parsed_value),
&"message": "Set %s.%s" % [node_path, property_name]
}
func _parse_typed_value(value, type_hint: int):
"""Parse a value based on its type hint."""
if type_hint == -1:
return _parse_value(value)
if typeof(value) == TYPE_DICTIONARY:
if value.has(&"type"):
return _parse_value(value)
match type_hint:
TYPE_VECTOR2:
return Vector2(value.get(&"x", 0), value.get(&"y", 0))
TYPE_VECTOR2I:
return Vector2i(value.get(&"x", 0), value.get(&"y", 0))
TYPE_VECTOR3:
return Vector3(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
TYPE_VECTOR3I:
return Vector3i(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
TYPE_COLOR:
return Color(value.get(&"r", 1), value.get(&"g", 1), value.get(&"b", 1), value.get(&"a", 1))
TYPE_RECT2:
return Rect2(value.get(&"x", 0), value.get(&"y", 0), value.get(&"width", 0), value.get(&"height", 0))
return value
@@ -0,0 +1 @@
uid://dbcix6lo0tanv
+333
View File
@@ -0,0 +1,333 @@
@tool
extends Node
class_name ScriptTools
## Script and file management tools for MCP.
## Handles: edit_script, validate_script, list_scripts,
## create_folder, delete_file, rename_file
var _editor_plugin: EditorPlugin = null
func set_editor_plugin(plugin: EditorPlugin) -> void:
_editor_plugin = plugin
func _refresh_filesystem() -> void:
if _editor_plugin:
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
func _ensure_res_path(path: String) -> String:
if not path.begins_with("res://"):
return "res://" + path
return path
# =============================================================================
# edit_script - Apply a small surgical code edit to a GDScript file
# =============================================================================
func edit_script(args: Dictionary) -> Dictionary:
var edit: Dictionary = args.get(&"edit", {})
if edit.is_empty():
return {&"ok": false, &"error": "Missing 'edit' payload"}
var path: String = str(edit.get(&"file", ""))
if path.is_empty():
return {&"ok": false, &"error": "Missing 'file' in edit"}
path = _ensure_res_path(path)
if not FileAccess.file_exists(path):
return {&"ok": false, &"error": "File not found: " + path}
var spec_type: String = str(edit.get(&"type", "snippet_replace"))
if spec_type != "snippet_replace":
return {&"ok": false, &"error": "Only 'snippet_replace' type is supported"}
var old_snippet: String = str(edit.get(&"old_snippet", ""))
var new_snippet: String = str(edit.get(&"new_snippet", ""))
var context_before: String = str(edit.get(&"context_before", ""))
var context_after: String = str(edit.get(&"context_after", ""))
if old_snippet.is_empty():
return {&"ok": false, &"error": "Missing 'old_snippet' in edit"}
# Read current file content
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return {&"ok": false, &"error": "Cannot read file: " + path}
var content := file.get_as_text()
file.close()
# Find and replace the snippet
var search_text := old_snippet
var pos := content.find(search_text)
# If not found directly, try with context
if pos == -1 and not context_before.is_empty():
var ctx_pos := content.find(context_before)
if ctx_pos != -1:
var after_ctx := ctx_pos + context_before.length()
var remaining := content.substr(after_ctx)
var snippet_pos := remaining.find(old_snippet)
if snippet_pos != -1:
pos = after_ctx + snippet_pos
if pos == -1:
return {&"ok": false, &"error": "Could not find old_snippet in file. Make sure old_snippet matches the file content exactly."}
# Check for multiple occurrences
var second_pos := content.find(search_text, pos + 1)
if second_pos != -1 and context_before.is_empty() and context_after.is_empty():
return {&"ok": false, &"error": "old_snippet appears multiple times. Add context_before or context_after for disambiguation."}
# Apply the replacement
var original_content := content
var new_content := content.substr(0, pos) + new_snippet + content.substr(pos + old_snippet.length())
# Write back
file = FileAccess.open(path, FileAccess.WRITE)
if not file:
return {&"ok": false, &"error": "Cannot write file: " + path}
file.store_string(new_content)
file.close()
# Count changes
var old_lines := old_snippet.split("\n")
var new_lines := new_snippet.split("\n")
var added := maxi(0, new_lines.size() - old_lines.size())
var removed := maxi(0, old_lines.size() - new_lines.size())
_refresh_filesystem()
return {
&"ok": true,
&"path": path,
&"added": added,
&"removed": removed,
&"auto_applied": true,
&"message": "Applied edit to %s (+%d -%d lines)" % [path, added, removed]
}
# =============================================================================
# validate_script
# =============================================================================
func validate_script(args: Dictionary) -> Dictionary:
var path: String = str(args.get(&"path", ""))
if path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'path'"}
path = _ensure_res_path(path)
if not FileAccess.file_exists(path):
return {&"ok": false, &"error": "File not found: " + path}
# Read the source text directly from disk so we validate the *current*
# file contents, not a stale resource-cache entry.
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return {&"ok": false, &"error": "Cannot read file: " + path}
var source_code := file.get_as_text()
file.close()
# Create a fresh GDScript instance and assign the source for parsing.
var script := GDScript.new()
script.source_code = source_code
# reload() triggers the parser/compiler and returns OK or an error code.
var err := script.reload()
if err != OK:
# Try to extract useful details from the Godot output log.
var errors := _collect_recent_script_errors(path)
return {
&"ok": true,
&"valid": false,
&"path": path,
&"error_code": err,
&"errors": errors,
&"message": "Script has errors." + (" Details: " + "; ".join(errors) if errors.size() > 0 else " Check Godot console for details.")
}
if not script.can_instantiate():
return {
&"ok": true,
&"valid": false,
&"path": path,
&"message": "Script parsed but cannot be instantiated (may have dependency errors)"
}
return {
&"ok": true,
&"valid": true,
&"path": path,
&"message": "No syntax errors found"
}
func _collect_recent_script_errors(script_path: String) -> Array:
"""Grab recent SCRIPT ERROR / Parse Error lines from the editor Output panel
that mention the given script path. Best-effort returns [] if the panel
cannot be accessed."""
var errors: Array = []
if not _editor_plugin:
return errors
# Find the editor's Output panel RichTextLabel
var base := _editor_plugin.get_editor_interface().get_base_control()
var editor_log := _find_node_by_class(base, "EditorLog")
if not editor_log:
return errors
var rtl := _find_child_rtl(editor_log)
if not rtl:
return errors
var text: String = rtl.get_parsed_text()
var short_path := script_path.get_file() # e.g. "player.gd"
for line: String in text.split("\n"):
line = line.strip_edges()
if line.is_empty():
continue
if short_path in line or script_path in line:
if line.begins_with("SCRIPT ERROR:") or line.begins_with("Parse Error:") \
or line.begins_with("ERROR:") or line.begins_with("at:"):
errors.append(line)
# Keep only the last 10 relevant lines
if errors.size() > 10:
errors = errors.slice(errors.size() - 10)
return errors
func _find_node_by_class(root: Node, cls_name: String) -> Node:
if root.get_class() == cls_name:
return root
for child: Node in root.get_children():
var found := _find_node_by_class(child, cls_name)
if found:
return found
return null
func _find_child_rtl(node: Node) -> RichTextLabel:
for child: Node in node.get_children():
if child is RichTextLabel:
return child
var found := _find_child_rtl(child)
if found:
return found
return null
# =============================================================================
# list_scripts
# =============================================================================
func list_scripts(args: Dictionary) -> Dictionary:
var scripts: Array = []
_collect_scripts("res://", scripts)
return {
&"ok": true,
&"scripts": scripts,
&"count": scripts.size()
}
func _collect_scripts(path: String, out: Array) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if name.begins_with("."):
name = dir.get_next()
continue
var full_path := path.path_join(name)
if dir.current_is_dir():
_collect_scripts(full_path, out)
elif name.ends_with(".gd"):
out.append(full_path)
name = dir.get_next()
dir.list_dir_end()
# =============================================================================
# create_folder
# =============================================================================
func create_folder(args: Dictionary) -> Dictionary:
var path: String = str(args.get(&"path", ""))
if path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'path'"}
path = _ensure_res_path(path)
if DirAccess.dir_exists_absolute(path):
return {&"ok": true, &"path": path, &"message": "Directory already exists"}
var err := DirAccess.make_dir_recursive_absolute(path)
if err != OK:
return {&"ok": false, &"error": "Failed to create directory: " + str(err)}
_refresh_filesystem()
return {&"ok": true, &"path": path, &"message": "Directory created"}
# =============================================================================
# delete_file
# =============================================================================
func delete_file(args: Dictionary) -> Dictionary:
var path: String = str(args.get(&"path", ""))
var confirm: bool = bool(args.get(&"confirm", false))
var create_backup: bool = bool(args.get(&"create_backup", true))
if path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'path'"}
if not confirm:
return {&"ok": false, &"error": "Must set confirm=true to delete"}
path = _ensure_res_path(path)
if not FileAccess.file_exists(path):
return {&"ok": false, &"error": "File not found: " + path}
# Create backup
if create_backup:
var backup_path := path + ".bak"
DirAccess.copy_absolute(path, backup_path)
var err := DirAccess.remove_absolute(path)
if err != OK:
return {&"ok": false, &"error": "Failed to delete file: " + str(err)}
_refresh_filesystem()
return {&"ok": true, &"path": path, &"message": "File deleted" + (" (backup created)" if create_backup else "")}
# =============================================================================
# rename_file
# =============================================================================
func rename_file(args: Dictionary) -> Dictionary:
var old_path: String = str(args.get(&"old_path", ""))
var new_path: String = str(args.get(&"new_path", ""))
if old_path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'old_path'"}
if new_path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'new_path'"}
old_path = _ensure_res_path(old_path)
new_path = _ensure_res_path(new_path)
if not FileAccess.file_exists(old_path):
return {&"ok": false, &"error": "File not found: " + old_path}
if FileAccess.file_exists(new_path):
return {&"ok": false, &"error": "Target already exists: " + new_path}
# Ensure target directory exists
var dir_path := new_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var err := DirAccess.rename_absolute(old_path, new_path)
if err != OK:
return {&"ok": false, &"error": "Failed to rename: " + str(err)}
_refresh_filesystem()
return {&"ok": true, &"old_path": old_path, &"new_path": new_path,
&"message": "Renamed %s to %s" % [old_path, new_path]}
@@ -0,0 +1 @@
uid://sqjhipl5ff5y
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
uid://cjktr607bngrs
-31
View File
@@ -1,31 +0,0 @@
# Contributors
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
Studio], and the community. The following is the list of community contributors
involved with netfox:
* Alberto Klocker <albertok@gmail.com>
* Andrew Davis <jonandrewdavis@gmail.com>
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
* Dan Schuman <danschuman@gmail.com>
* Dustie <77035922+DustieDog@users.noreply.github.com>
* Eric Volpone <ericvolpone@gmail.com>
* Gordon MacPherson <gordon@gordonite.tech>
* Jake Cattrall <krazyjakee@gmail.com>
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
* Joseph Michael Ware <9at25jnr3@mozmail.com>
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
* Nicolas Batty <nicolas.batty@gmail.com>
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
* Russ <622740+russ-@users.noreply.github.com>
* Ryan Roden-Corrent <github@rcorre.net>
* TheYellowArchitect <dmalandris@uth.gr>
* TheYellowArchitect <hello@theyellowarchitect.com>
* camperotactico <alberto.vgdd@gmail.com>
* gk98s <89647115+gk98s@users.noreply.github.com>
* zibetnu <9at25jnr3@mozmail.com>
[netfox]: https://github.com/foxssake/netfox
[Fox's Sake Studio]: https://github.com/foxssake/
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bhm6yivew52yv"
path="res://.godot/imported/network-rigid-body-2d.svg-c7ef7df16c4383a80b842fa966aa7aea.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox.extras/icons/network-rigid-body-2d.svg"
dest_files=["res://.godot/imported/network-rigid-body-2d.svg-c7ef7df16c4383a80b842fa966aa7aea.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b2lri7ofmq6a7"
path="res://.godot/imported/network-rigid-body-3d.svg-325e3a4834ddab37f186912ac93fa449.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox.extras/icons/network-rigid-body-3d.svg"
dest_files=["res://.godot/imported/network-rigid-body-3d.svg-325e3a4834ddab37f186912ac93fa449.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cx8j75i6acsic"
path="res://.godot/imported/rewindable-state-machine.svg-87ca202891a7e2319363a5e2f5387494.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox.extras/icons/rewindable-state-machine.svg"
dest_files=["res://.godot/imported/rewindable-state-machine.svg-87ca202891a7e2319363a5e2f5387494.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dsxkv3ufy1r2q"
path="res://.godot/imported/rewindable-state.svg-b534e5a6f5de20b3fe0b63d612bd36bf.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox.extras/icons/rewindable-state.svg"
dest_files=["res://.godot/imported/rewindable-state.svg-b534e5a6f5de20b3fe0b63d612bd36bf.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
+7 -2
View File
@@ -109,7 +109,8 @@ func _enter_tree():
add_setting(setting)
for autoload in AUTOLOADS:
add_autoload_singleton(autoload.name, autoload.path)
if not has_autoload(autoload.name):
add_autoload_singleton(autoload.name, autoload.path)
for type in TYPES:
add_custom_type(type.name, type.base, load(type.script), load(type.icon))
@@ -122,7 +123,8 @@ func _exit_tree():
remove_setting(setting)
for autoload in AUTOLOADS:
remove_autoload_singleton(autoload.name)
if has_autoload(autoload.name):
remove_autoload_singleton(autoload.name)
for type in TYPES:
remove_custom_type(type.name)
@@ -148,6 +150,9 @@ func remove_setting(setting: Dictionary):
ProjectSettings.clear(setting.name)
func has_autoload(name: String) -> bool:
return ProjectSettings.has_setting("autoload/" + name)
func _render_tool_menu():
_free_tool_menu()
for driver_toggle in PhysicsDriverToggles.all():
@@ -1,18 +1,38 @@
extends PhysicsDriver
class_name RapierDriver2D
var _state: StateManager2D
var _stored_states: int = 0
func _init_physics_space() -> void:
physics_space = get_viewport().world_2d.space
PhysicsServer2D.space_set_active(physics_space, false)
_state = StateManager2D.new()
_state.root_node = self
_state.set_max_cache_length(ProjectSettings.get_setting("netfox/rollback/history_limit", 64))
_state.set_rolling_cache(true)
add_child(_state)
func _physics_step(delta) -> void:
RapierPhysicsServer2D.space_step(physics_space, delta)
RapierPhysicsServer2D.space_flush_queries(physics_space)
func _snapshot_space(tick: int) -> void:
snapshots[tick] = RapierPhysicsServer2D.export_binary(physics_space)
func _rollback_space(tick) -> void:
if snapshots.has(tick):
RapierPhysicsServer2D.import_binary(physics_space, snapshots[tick])
func _snapshot_space(tick: int) -> void:
_state.cache_state(physics_space, tick)
func _rollback_space(tick: int) -> void:
# With rolling cache, tick states are ordered by age with the newest at 0
var offset = NetworkTime.tick - tick
if (offset >= _stored_states):
return
_stored_states = min(_stored_states + 1, _state.max_cache_length)
_state.load_cached_state(physics_space, offset)
@@ -2,17 +2,36 @@ extends PhysicsDriver
class_name RapierDriver3D
var _state: StateManager3D
var _stored_states: int = 0
func _init_physics_space() -> void:
physics_space = get_viewport().world_3d.space
PhysicsServer3D.space_set_active(physics_space, false)
_state = StateManager3D.new()
_state.root_node = self
_state.set_max_cache_length(ProjectSettings.get_setting("netfox/rollback/history_limit", 64))
_state.set_rolling_cache(true)
add_child(_state)
func _physics_step(delta) -> void:
RapierPhysicsServer3D.space_step(physics_space, delta)
RapierPhysicsServer3D.space_flush_queries(physics_space)
func _snapshot_space(tick: int) -> void:
snapshots[tick] = RapierPhysicsServer3D.export_binary(physics_space)
func _rollback_space(tick) -> void:
if snapshots.has(tick):
RapierPhysicsServer3D.import_binary(physics_space, snapshots[tick])
func _snapshot_space(tick: int) -> void:
_state.cache_state(physics_space, tick)
func _rollback_space(tick: int) -> void:
# With rolling cache, tick states are ordered by age with the newest at 0
var offset = NetworkTime.tick - tick
if (offset >= _stored_states):
return
_stored_states = min(_stored_states + 1, _state.max_cache_length)
_state.load_cached_state(physics_space, offset)
+1 -1
View File
@@ -3,5 +3,5 @@
name="netfox.extras"
description="Game-specific utilities for Netfox"
author="Tamas Galffy and contributors"
version="1.35.3"
version="1.40.2"
script="netfox-extras.gd"
-31
View File
@@ -1,31 +0,0 @@
# Contributors
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
Studio], and the community. The following is the list of community contributors
involved with netfox:
* Alberto Klocker <albertok@gmail.com>
* Andrew Davis <jonandrewdavis@gmail.com>
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
* Dan Schuman <danschuman@gmail.com>
* Dustie <77035922+DustieDog@users.noreply.github.com>
* Eric Volpone <ericvolpone@gmail.com>
* Gordon MacPherson <gordon@gordonite.tech>
* Jake Cattrall <krazyjakee@gmail.com>
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
* Joseph Michael Ware <9at25jnr3@mozmail.com>
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
* Nicolas Batty <nicolas.batty@gmail.com>
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
* Russ <622740+russ-@users.noreply.github.com>
* Ryan Roden-Corrent <github@rcorre.net>
* TheYellowArchitect <dmalandris@uth.gr>
* TheYellowArchitect <hello@theyellowarchitect.com>
* camperotactico <alberto.vgdd@gmail.com>
* gk98s <89647115+gk98s@users.noreply.github.com>
* zibetnu <9at25jnr3@mozmail.com>
[netfox]: https://github.com/foxssake/netfox
[Fox's Sake Studio]: https://github.com/foxssake/
+95
View File
@@ -0,0 +1,95 @@
extends RefCounted
class_name _Bitset
# Stores a list of booleans, representing them efficiently as a PackedByteArray
var _data: PackedByteArray
var _bit_count: int
static func of_bools(values: Array) -> _Bitset:
var result := _Bitset.new(values.size())
for i in values.size():
if values[i]:
result.set_bit(i)
return result
func _init(bit_count: int):
var bytes := bit_count / 8
if bit_count % 8 > 0:
bytes += 1
_data = PackedByteArray()
_data.resize(bytes)
_bit_count = bit_count
func bit_count() -> int:
return _bit_count
func is_empty() -> bool:
return _bit_count == 0
func is_not_empty() -> bool:
return _bit_count != 0
func get_bit(idx: int) -> bool:
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
var byte_idx := idx / 8
var bit_idx := idx % 8
return (_data[byte_idx] >> bit_idx) & 0x1 != 0
func set_bit(idx: int) -> void:
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
var byte_idx := idx / 8
var bit_idx := idx % 8
_data[byte_idx] |= 0x1 << bit_idx
func clear_bit(idx: int) -> void:
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
var byte_idx := idx / 8
var bit_idx := idx % 8
_data[byte_idx] &= ~(0x1 << bit_idx)
func toggle_bit(idx: int) -> void:
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
var byte_idx := idx / 8
var bit_idx := idx % 8
_data[byte_idx] ^= 0x1 << bit_idx
func get_set_indices() -> Array[int]:
var result := [] as Array[int]
for i in _data.size():
var byte := _data[i]
if byte & 0x01: result.append(i * 8 + 0)
if byte & 0x02: result.append(i * 8 + 1)
if byte & 0x04: result.append(i * 8 + 2)
if byte & 0x08: result.append(i * 8 + 3)
if byte & 0x10: result.append(i * 8 + 4)
if byte & 0x20: result.append(i * 8 + 5)
if byte & 0x40: result.append(i * 8 + 6)
if byte & 0x80: result.append(i * 8 + 7)
return result
func equals(other) -> bool:
if other is _Bitset:
return other._bit_count == _bit_count and other._data == _data
else:
return false
func _to_string() -> String:
if is_empty():
return "Bitset(n=0)"
else:
var body := ""
for i in _bit_count:
if i != 0 and i % 4 == 0:
body += " "
if get_bit(i): body += "1"
else: body += "0"
return "Bitset(n=%d, %s)" % [_bit_count, body]
+1
View File
@@ -0,0 +1 @@
uid://c46ub48lgga12
+57
View File
@@ -0,0 +1,57 @@
extends RefCounted
class_name _Graph
# Represents a graph, in the sense of a set of nodes, arbitrarily connected by
# links
var _links_from := {} # `from` to `to[]`
var _links_to := {} # `to` to `from[]`
func link(from: Variant, to: Variant) -> void:
if has_link(from, to):
return
_append(_links_from, from, to)
_append(_links_to, to, from)
func unlink(from: Variant, to: Variant) -> void:
_erase(_links_from, from, to)
_erase(_links_to, to, from)
func erase(node: Variant) -> void:
var links_to := _links_from.get(node, [])
var links_from := _links_to.get(node, [])
_links_from.erase(node)
_links_to.erase(node)
for link in links_to:
_erase(_links_to, link, node)
for link in links_from:
_erase(_links_from, link, node)
func get_linked_from(from: Variant) -> Array:
return _links_from.get(from, [])
func get_linked_to(to: Variant) -> Array:
return _links_to.get(to, [])
func has_link(from: Variant, to: Variant) -> bool:
return get_linked_from(from).has(to)
func _append(pool: Dictionary, key: Variant, value: Variant) -> void:
if not pool.has(key):
pool[key] = [value]
else:
pool[key].append(value)
func _erase(pool: Dictionary, key: Variant, value: Variant) -> void:
if not pool.has(key):
return
var values := pool[key] as Array
values.erase(value)
if values.is_empty():
pool.erase(key)
+1
View File
@@ -0,0 +1 @@
uid://djknl4n6akxue
+113 -49
View File
@@ -1,70 +1,134 @@
extends RefCounted
class_name _HistoryBuffer
# Maps ticks (int) to arbitrary data
var _buffer: Dictionary = {}
# Maps ticks (int) to arbitrary data, stored in a sliding ring buffer
func get_snapshot(tick: int):
if _buffer.has(tick):
return _buffer[tick]
else:
return null
var _capacity := 64
var _buffer := []
var _previous := []
func set_snapshot(tick: int, data):
_buffer[tick] = data
var _tail := 0
var _head := 0
func get_buffer() -> Dictionary:
return _buffer
static func of(capacity: int, data: Dictionary) -> _HistoryBuffer:
var history_buffer := _HistoryBuffer.new(capacity)
for idx in data:
history_buffer.set_at(idx, data[idx])
return history_buffer
func get_earliest_tick() -> int:
return _buffer.keys().min()
func _init(capacity: int = 64):
_capacity = capacity
_buffer.resize(_capacity)
_previous.resize(_capacity)
func get_latest_tick() -> int:
return _buffer.keys().max()
func duplicate(deep: bool = false) -> _HistoryBuffer:
var buffer := _HistoryBuffer.new(_capacity)
func get_closest_tick(tick: int) -> int:
if _buffer.has(tick):
return tick
buffer._buffer = _buffer.duplicate(deep)
buffer._previous = _previous.duplicate()
buffer._tail = _tail
buffer._head = _head
if _buffer.is_empty():
return -1
return buffer
var earliest_tick = get_earliest_tick()
if tick < earliest_tick:
return earliest_tick
func push(value: Variant) -> void:
_buffer[_head % _capacity] = value
_previous[_head % _capacity] = _head
_head += 1
_tail += maxi(0, size() - capacity())
var latest_tick = get_latest_tick()
if tick > latest_tick:
return latest_tick
func pop() -> Variant:
assert(is_not_empty(), "History buffer is empty!")
return _buffer.keys() \
.filter(func (key): return key < tick) \
.max()
var value = _buffer[_tail % _capacity]
_tail += 1
return value
func get_history(tick: int):
var closest_tick = get_closest_tick(tick)
return _buffer.get(closest_tick)
func set_at(at: int, value: Variant) -> void:
# Why does this need so many branches?
if is_empty():
# Buffer is empty, jump to specified index
_tail = at
_head = at
push(value)
elif at < _head - capacity():
# Trying to set something that would wrap back around and overwrite
# current data
return
elif at == _head:
# Simply adding a new item
push(value)
elif at < _head:
_buffer[at % _capacity] = value
# Update prev-buffer
for i in range(at, _head):
if _previous[i % _capacity] == i:
break
_previous[i % _capacity] = at
_tail = mini(_tail, at)
elif at >= _head + _capacity:
# We're leaving all data behind
_tail = at
_head = at
func trim(earliest_tick_to_keep: int):
var ticks := _buffer.keys()
for tick in ticks:
if tick < earliest_tick_to_keep:
_buffer.erase(tick)
_previous.fill(null)
_buffer.fill(null)
func clear():
_buffer.clear()
push(value)
elif at >= _head:
# Skipping forward a bit
var previous := _head - 1
while _head < at:
_previous[_head % _capacity] = previous
_head += 1
_tail += maxi(0, size() - _capacity)
push(value)
func has_at(at: int) -> bool:
if is_empty(): return false
if at < _head - capacity(): return false
if at >= _head: return false
return _previous[at % _capacity] == at
func get_at(at: int, default: Variant = null) -> Variant:
if not has_at(at):
return default
return _buffer[at % _capacity]
func has_latest_at(at: int) -> bool:
if is_empty(): return false
if at < _tail: return false
return true
func size() -> int:
return _buffer.size()
return _head - _tail
func capacity() -> int:
return _capacity
func get_earliest_index() -> int:
return _tail
func get_latest_index() -> int:
return _head - 1
func get_latest_index_at(at: int) -> int:
if not has_latest_at(at):
return -1
if at >= _head:
return get_latest_index()
return _previous[at % _capacity]
func get_latest_at(at: int) -> Variant:
return get_at(get_latest_index_at(at))
func clear():
_tail = _head
func is_empty() -> bool:
return _buffer.is_empty()
return size() == 0
func has(tick) -> bool:
return _buffer.has(tick)
func ticks() -> Array:
return _buffer.keys()
func erase(tick):
_buffer.erase(tick)
func is_not_empty() -> bool:
return not is_empty()
@@ -0,0 +1,22 @@
extends RefCounted
class_name _IntervalScheduler
# Returns true on every nth `is_now()` call
var interval := 1
var _idx := 0
func _init(p_interval: int = 1):
interval = p_interval
func is_now() -> bool:
if interval <= 0:
return false
elif interval == 1:
return true
elif _idx + 1 >= interval:
_idx = 0
return true
else:
_idx += 1
return false
@@ -0,0 +1 @@
uid://egfuyvoj0r2s
+1 -1
View File
@@ -3,5 +3,5 @@
name="netfox.internals"
description="Shared internals for netfox addons"
author="Tamas Galffy and contributors"
version="1.35.3"
version="1.40.2"
script="plugin.gd"
+8
View File
@@ -10,6 +10,11 @@ static func of(items: Array) -> _Set:
result.add(item)
return result
func duplicate(deep: bool = false) -> _Set:
var result := _Set.new()
result._data = _data.duplicate(deep)
return result
func add(value):
_data[value] = true
@@ -46,6 +51,9 @@ func equals(other) -> bool:
func _to_string():
return "Set" + str(values())
func _to_vest():
return _data.keys()
func _iter_init(arg) -> bool:
_iterator_idx = 0
return _can_iterate()
-31
View File
@@ -1,31 +0,0 @@
# Contributors
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
Studio], and the community. The following is the list of community contributors
involved with netfox:
* Alberto Klocker <albertok@gmail.com>
* Andrew Davis <jonandrewdavis@gmail.com>
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
* Dan Schuman <danschuman@gmail.com>
* Dustie <77035922+DustieDog@users.noreply.github.com>
* Eric Volpone <ericvolpone@gmail.com>
* Gordon MacPherson <gordon@gordonite.tech>
* Jake Cattrall <krazyjakee@gmail.com>
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
* Joseph Michael Ware <9at25jnr3@mozmail.com>
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
* Nicolas Batty <nicolas.batty@gmail.com>
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
* Russ <622740+russ-@users.noreply.github.com>
* Ryan Roden-Corrent <github@rcorre.net>
* TheYellowArchitect <dmalandris@uth.gr>
* TheYellowArchitect <hello@theyellowarchitect.com>
* camperotactico <alberto.vgdd@gmail.com>
* gk98s <89647115+gk98s@users.noreply.github.com>
* zibetnu <9at25jnr3@mozmail.com>
[netfox]: https://github.com/foxssake/netfox
[Fox's Sake Studio]: https://github.com/foxssake/
+1 -1
View File
@@ -20,7 +20,7 @@ See the root [README](../../README.md).
## Usage
See the [docs](https://foxssake.github.io/netfox/netfox.noray/guides/noray/).
See the [docs](https://foxssake.github.io/netfox/latest/netfox.noray/guides/noray/).
For a full example, see [noray-bootstrapper.gd].
+7 -2
View File
@@ -23,7 +23,8 @@ func _enter_tree():
add_setting(setting)
for autoload in AUTOLOADS:
add_autoload_singleton(autoload.name, autoload.path)
if not has_autoload(autoload.name):
add_autoload_singleton(autoload.name, autoload.path)
func _exit_tree():
if ProjectSettings.get_setting("netfox/general/clear_settings", false):
@@ -31,7 +32,8 @@ func _exit_tree():
remove_setting(setting)
for autoload in AUTOLOADS:
remove_autoload_singleton(autoload.name)
if has_autoload(autoload.name):
remove_autoload_singleton(autoload.name)
func add_setting(setting: Dictionary):
if ProjectSettings.has_setting(setting.name):
@@ -51,3 +53,6 @@ func remove_setting(setting: Dictionary):
return
ProjectSettings.clear(setting.name)
func has_autoload(name: String) -> bool:
return ProjectSettings.has_setting("autoload/" + name)
+1 -1
View File
@@ -3,5 +3,5 @@
name="netfox.noray"
description="Bulletproof your connectivity with noray integration for netfox"
author="Tamas Galffy and contributors"
version="1.35.3"
version="1.40.2"
script="netfox-noray.gd"
-31
View File
@@ -1,31 +0,0 @@
# Contributors
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
Studio], and the community. The following is the list of community contributors
involved with netfox:
* Alberto Klocker <albertok@gmail.com>
* Andrew Davis <jonandrewdavis@gmail.com>
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
* Dan Schuman <danschuman@gmail.com>
* Dustie <77035922+DustieDog@users.noreply.github.com>
* Eric Volpone <ericvolpone@gmail.com>
* Gordon MacPherson <gordon@gordonite.tech>
* Jake Cattrall <krazyjakee@gmail.com>
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
* Joseph Michael Ware <9at25jnr3@mozmail.com>
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
* Nicolas Batty <nicolas.batty@gmail.com>
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
* Russ <622740+russ-@users.noreply.github.com>
* Ryan Roden-Corrent <github@rcorre.net>
* TheYellowArchitect <dmalandris@uth.gr>
* TheYellowArchitect <hello@theyellowarchitect.com>
* camperotactico <alberto.vgdd@gmail.com>
* gk98s <89647115+gk98s@users.noreply.github.com>
* zibetnu <9at25jnr3@mozmail.com>
[netfox]: https://github.com/foxssake/netfox
[Fox's Sake Studio]: https://github.com/foxssake/
@@ -1,136 +0,0 @@
extends RefCounted
class_name _DiffHistoryEncoder
var _history: _PropertyHistoryBuffer
var _property_cache: PropertyCache
var _full_snapshot := {}
var _encoded_snapshot := {}
var _property_indexes := _BiMap.new()
var _version := 0
var _has_received := false
static var _logger := NetfoxLogger._for_netfox("DiffHistoryEncoder")
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
_history = p_history
_property_cache = p_property_cache
func add_properties(properties: Array[PropertyEntry]) -> void:
var has_new_properties := false
for property_entry in properties:
var is_new := _ensure_property_idx(property_entry.to_string())
has_new_properties = has_new_properties or is_new
# If we added any new properties, increment version
if has_new_properties:
_version = (_version + 1) % 256
func encode(tick: int, reference_tick: int, properties: Array[PropertyEntry]) -> PackedByteArray:
assert(properties.size() <= 255, "Property indices may not fit into bytes!")
var snapshot := _history.get_snapshot(tick)
var property_strings := properties.map(func(it): return it.to_string())
var reference_snapshot := _history.get_history(reference_tick)
var diff_snapshot := reference_snapshot.make_patch(snapshot)
_full_snapshot = snapshot.as_dictionary()
_encoded_snapshot = diff_snapshot.as_dictionary()
if diff_snapshot.is_empty():
return PackedByteArray()
var buffer := StreamPeerBuffer.new()
buffer.put_u8(_version)
for property in diff_snapshot.properties():
var property_idx := _property_indexes.get_by_value(property) as int
var property_value = diff_snapshot.get_value(property)
buffer.put_u8(property_idx)
buffer.put_var(property_value)
return buffer.data_array
func decode(data: PackedByteArray, properties: Array[PropertyEntry]) -> _PropertySnapshot:
var result := _PropertySnapshot.new()
if data.is_empty():
return result
var buffer := StreamPeerBuffer.new()
buffer.data_array = data
var packet_version := buffer.get_u8()
if packet_version != _version:
if not _has_received:
# This is the first time we receive data
# Assume the version is OK
_version = packet_version
else:
# Since we don't remove entries, only add, we can still parse what
# we can
_logger.warning("Property config version mismatch - own %d != received %d", [_version, packet_version])
_has_received = true
while buffer.get_available_bytes() > 0:
var property_idx := buffer.get_u8()
var property_value := buffer.get_var()
if not _property_indexes.has_key(property_idx):
_logger.warning("Received unknown property index %d, ignoring!", [property_idx])
continue
var property_entry := _property_indexes.get_by_key(property_idx)
result.set_value(property_entry, property_value)
return result
func apply(tick: int, snapshot: _PropertySnapshot, reference_tick: int, sender: int = -1) -> bool:
if tick < NetworkRollback.history_start:
# State too old!
_logger.error(
"Received diff snapshot for @%d, rejecting because older than %s frames",
[tick, NetworkRollback.history_limit]
)
return false
if snapshot.is_empty():
return true
if sender > 0:
snapshot.sanitize(sender, _property_cache)
if snapshot.is_empty():
_logger.warning("Received invalid diff from #%s for @%s", [sender, tick])
return false
if not _history.has(reference_tick):
# Reference tick missing, hope for the best
_logger.warning("Reference tick %d missing for #%s applying %d", [reference_tick, sender, tick])
var reference_snapshot := _history.get_snapshot(reference_tick)
_history.set_snapshot(tick, reference_snapshot.merge(snapshot))
return true
# TODO: Rework metrics so these are not needed
func get_encoded_snapshot() -> Dictionary:
return _encoded_snapshot
func get_full_snapshot() -> Dictionary:
return _full_snapshot
func _ensure_property_idx(property: String) -> bool:
if _property_indexes.has_value(property):
return false
assert(_property_indexes.size() < 256, "Property index map is full, can't add new property!")
var idx := hash(property) % 256
while _property_indexes.has_key(idx):
idx = hash(idx + 1) % 256
_property_indexes.put(idx, property)
return true
@@ -1 +0,0 @@
uid://dc73evbedbmvs
@@ -1,116 +0,0 @@
extends RefCounted
class_name _RedundantHistoryEncoder
var redundancy: int = 4:
get = get_redundancy,
set = set_redundancy
var _history: _PropertyHistoryBuffer
var _properties: Array[PropertyEntry]
var _property_cache: PropertyCache
var _version := 0
var _has_received := false
var _logger := NetfoxLogger._for_netfox("RedundantHistoryEncoder")
func get_redundancy() -> int:
return redundancy
func set_redundancy(p_redundancy: int):
if p_redundancy <= 0:
_logger.warning(
"Attempting to set redundancy to %d, which would send no data!", [p_redundancy]
)
return
redundancy = p_redundancy
func set_properties(properties: Array[PropertyEntry]) -> void:
if _properties != properties:
_version = (_version + 1) % 256
_properties = properties.duplicate()
func encode(tick: int, properties: Array[PropertyEntry]) -> Array:
if _history.is_empty():
return []
var data := []
for i in range(mini(redundancy, _history.size())):
var offset_tick := tick - i
if offset_tick < _history.get_earliest_tick():
break
var snapshot := _history.get_snapshot(offset_tick)
for property in properties:
data.append(snapshot.get_value(property.to_string()))
data.append(_version)
return data
func decode(data: Array, properties: Array[PropertyEntry]) -> Array[_PropertySnapshot]:
if data.is_empty() or properties.is_empty():
return []
var packet_version := data.pop_back() as int
if packet_version != _version:
if not _has_received:
# First packet, assume version is OK
_version = packet_version
else:
# Version mismatch, can't parse
_logger.warning("Version mismatch! own: %d, received: %s", [_version, packet_version])
return []
var result: Array[_PropertySnapshot] = []
var redundancy := data.size() / properties.size()
result.assign(range(redundancy)
.map(func(__): return _PropertySnapshot.new())
)
for i in range(data.size()):
var offset_idx := i / properties.size()
var prop_idx := i % properties.size()
result[offset_idx].set_value(properties[prop_idx].to_string(), data[i])
_has_received = true
return result
# Returns earliest new tick as int, or -1 if no new ticks applied
func apply(tick: int, snapshots: Array[_PropertySnapshot], sender: int = 0) -> int:
var earliest_new_tick = -1
for i in range(snapshots.size()):
var offset_tick := tick - i
var snapshot := snapshots[i]
if offset_tick < NetworkRollback.history_start:
# Data too old
_logger.warning(
"Received data for %s, rejecting because older than %s frames",
[offset_tick, NetworkRollback.history_limit]
)
continue
if sender > 0:
snapshot.sanitize(sender, _property_cache)
if snapshot.is_empty():
# No valid properties ( probably after sanitize )
_logger.warning("Received invalid data from %d for tick %d", [sender, tick])
continue
var known_snapshot := _history.get_snapshot(offset_tick)
if not known_snapshot.equals(snapshot):
# Received a new snapshot, store and emit signal
_history.set_snapshot(offset_tick, snapshot)
earliest_new_tick = offset_tick
return earliest_new_tick
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
_history = p_history
_property_cache = p_property_cache
@@ -1 +0,0 @@
uid://ytn07qahpatv
@@ -1,67 +0,0 @@
extends RefCounted
class_name _SnapshotHistoryEncoder
var _history: _PropertyHistoryBuffer
var _property_cache: PropertyCache
var _properties: Array[PropertyEntry]
var _version := -1
var _has_received := false
static var _logger := NetfoxLogger._for_netfox("_SnapshotHistoryEncoder")
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
_history = p_history
_property_cache = p_property_cache
func set_properties(properties: Array[PropertyEntry]) -> void:
if _properties != properties:
_version = (_version + 1) % 256
_properties = properties.duplicate()
func encode(tick: int, properties: Array[PropertyEntry]) -> Array:
var snapshot := _history.get_snapshot(tick)
var data := []
data.resize(properties.size())
for i in range(properties.size()):
data[i] = snapshot.get_value(properties[i].to_string())
data.append(_version)
return data
func decode(data: Array, properties: Array[PropertyEntry]) -> _PropertySnapshot:
var result := _PropertySnapshot.new()
var packet_version = data.pop_back()
if packet_version != _version:
if not _has_received:
# First packet, assume version is OK
_version = packet_version
else:
# Version mismatch, can't parse
_logger.warning("Version mismatch! own: %d, received: %s", [_version, packet_version])
return result
if properties.size() != data.size():
_logger.warning("Received snapshot with %d entries, with %d known - parsing as much as possible", [data.size(), properties.size()])
for i in range(0, mini(data.size(), properties.size())):
result.set_value(properties[i].to_string(), data[i])
_has_received = true
return result
func apply(tick: int, snapshot: _PropertySnapshot, sender: int = -1) -> bool:
if tick < NetworkRollback.history_start:
# State too old!
_logger.error("Received full snapshot for %s, rejecting because older than %s frames", [tick, NetworkRollback.history_limit])
return false
if sender > 0:
snapshot.sanitize(sender, _property_cache)
if snapshot.is_empty(): return false
_history.set_snapshot(tick, snapshot)
return true
@@ -1 +0,0 @@
uid://dwhlghjsgdy4o
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 16 16"
xml:space="preserve"
sodipodi:docname="predictive-synchronizer.svg"
width="16"
height="16"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1"><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect2"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,1,0,1 @ F,0,0,1,0,1,0,1 @ F,0,0,1,0,1,0,1 @ F,0,0,1,0,1,0,1"
radius="1"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /><inkscape:path-effect
effect="fillet_chamfer"
id="path-effect1"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" /></defs><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="24.40625"
inkscape:cx="8.8297055"
inkscape:cy="10.140845"
inkscape:window-width="2560"
inkscape:window-height="1368"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1"><inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14902"
empspacing="5"
enabled="true"
visible="false" /></sodipodi:namedview>
<style
type="text/css"
id="style1">
.st0{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;}
.st1{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.66;}
.st2{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33;}
.st3{fill:#5FFF97;}
.st4{fill:#FF5F5F;}
</style>
<path
id="path6"
class="st0"
d="M 8,8 10.425,5.575"
style="stroke-width:2;stroke-dasharray:none" />
<path
id="path7"
class="st1"
d="M 8,8 V 4.575"
style="stroke-width:2;stroke-dasharray:none" />
<path
id="path8"
class="st2"
d="M 8,8 5.575,5.575"
style="stroke-width:2;stroke-dasharray:none" />
<circle
id="path4"
class="st0"
cx="8"
cy="8"
style="stroke-width:2;stroke-dasharray:none"
r="5" />
<path
style="fill:#6f8a91"
id="rect1"
width="8"
height="1.8"
x="4.4037995"
y="11.704836"
sodipodi:type="rect"
d="M 5.4037995,11.704836 H 11.4038 a 1,1 45 0 1 1,1 0.90553849,0.90553849 141.34019 0 1 -1,0.8 H 5.4037995 a 1,1 45 0 1 -1,-1 0.90553849,0.90553849 141.34019 0 1 1,-0.8 z"
inkscape:path-effect="#path-effect2"
transform="translate(-0.40379953,-2.1992974)" /></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dsr2mjcr1lh6v"
path="res://.godot/imported/predictive-synchronizer.svg-c1a256fe714777cb768d64b354a43e2b.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox/icons/predictive-synchronizer.svg"
dest_files=["res://.godot/imported/predictive-synchronizer.svg-c1a256fe714777cb768d64b354a43e2b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dy3qj4jbh0enm"
path="res://.godot/imported/rewindable-action.svg-2eb32b564a2fe4ecb8b77ec5b55683ab.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox/icons/rewindable-action.svg"
dest_files=["res://.godot/imported/rewindable-action.svg-2eb32b564a2fe4ecb8b77ec5b55683ab.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://csg26ysqqb4xe"
path="res://.godot/imported/rollback-synchronizer.svg-99c6071e1009de5a35a481b2f486c380.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox/icons/rollback-synchronizer.svg"
dest_files=["res://.godot/imported/rollback-synchronizer.svg-99c6071e1009de5a35a481b2f486c380.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ogbi1hffcoyh"
path="res://.godot/imported/state-synchronizer.svg-9cb9447ba79f114a58e468a24d17b860.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox/icons/state-synchronizer.svg"
dest_files=["res://.godot/imported/state-synchronizer.svg-9cb9447ba79f114a58e468a24d17b860.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
@@ -0,0 +1,44 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cpaqxcohxtb68"
path="res://.godot/imported/tick-interpolator.svg-c60124cd7d287f516c89a6022efef330.ctex"
metadata={
"has_editor_variant": true,
"vram_texture": false
}
[deps]
source_file="res://addons/netfox/icons/tick-interpolator.svg"
dest_files=["res://.godot/imported/tick-interpolator.svg-c60124cd7d287f516c89a6022efef330.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=true
editor/convert_colors_with_editor_theme=true
+68 -2
View File
@@ -10,6 +10,11 @@ var SETTINGS: Array[Dictionary] = [
"value": true,
"type": TYPE_BOOL
},
{
"name": "netfox/general/use_raw_commands",
"value": false,
"type": TYPE_BOOL
},
# Logging
NetfoxLogger._make_setting("netfox/logging/netfox_log_level"),
# Time settings
@@ -112,11 +117,41 @@ var SETTINGS: Array[Dictionary] = [
"hint": PROPERTY_HINT_RANGE,
"hint_string": "0,4,or_greater"
},
{
"name": "netfox/rollback/enable_input_broadcast",
"value": false,
"type": TYPE_BOOL
},
{
"name": "netfox/rollback/enable_diff_states",
"value": true,
"type": TYPE_BOOL
},
{
"name": "netfox/rollback/full_state_interval",
"value": 24,
"type": TYPE_INT,
"hint": PROPERTY_HINT_RANGE,
"hint_string": "0,60,or_greater"
},
# StateSynchronizer
{
"name": "netfox/state_synchronizer/enable_diff_states",
"value": true,
"type": TYPE_BOOL
},
{
"name": "netfox/state_synchronizer/history_limit",
"value": 64,
"type": TYPE_INT
},
{
"name": "netfox/state_synchronizer/full_state_interval",
"value": 24,
"type": TYPE_INT,
"hint": PROPERTY_HINT_RANGE,
"hint_string": "0,60,or_greater"
},
# Events
{
"name": "netfox/events/enabled",
@@ -145,6 +180,26 @@ const AUTOLOADS: Array[Dictionary] = [
{
"name": "NetworkPerformance",
"path": ROOT + "/network-performance.gd"
},
{
"name": "RollbackSimulationServer",
"path": ROOT + "/servers/rollback-simulation-server.gd"
},
{
"name": "NetworkHistoryServer",
"path": ROOT + "/servers/network-history-server.gd"
},
{
"name": "NetworkSynchronizationServer",
"path": ROOT + "/servers/network-synchronization-server.gd"
},
{
"name": "NetworkIdentityServer",
"path": ROOT + "/servers/network-identity-server.gd"
},
{
"name": "NetworkCommandServer",
"path": ROOT + "/servers/network-command-server.gd"
}
]
@@ -173,6 +228,12 @@ 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():
@@ -180,7 +241,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))
@@ -191,7 +253,8 @@ func _exit_tree() -> void:
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)
@@ -214,3 +277,6 @@ func remove_setting(setting: Dictionary) -> void:
return
ProjectSettings.clear(setting.name)
func has_autoload(name: String) -> bool:
return ProjectSettings.has_setting("autoload/" + name)
+6
View File
@@ -106,12 +106,18 @@ func get_sent_state_props_ratio() -> float:
func push_full_state(state: Dictionary) -> void:
_full_state_props_accum += state.size()
func push_full_state_props(count: int) -> void:
_full_state_props_accum += count
func push_full_state_broadcast(state: Dictionary) -> void:
_full_state_props_accum += state.size() * (multiplayer.get_peers().size() - 1)
func push_sent_state(state: Dictionary) -> void:
_sent_state_props_accum += state.size()
func push_sent_state_props(count: int) -> void:
_sent_state_props_accum += count
func push_sent_state_broadcast(state: Dictionary) -> void:
_sent_state_props_accum += state.size() * (multiplayer.get_peers().size() - 1)
+22 -13
View File
@@ -115,6 +115,11 @@ var _offset: float = 0.
var _rtt: float = 0.
var _rtt_jitter: float = 0.
@onready var _cmd_ping := NetworkCommandServer.register_command(_handle_ping, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
@onready var _cmd_pong := NetworkCommandServer.register_command(_handle_pong, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
@onready var _cmd_req_time := NetworkCommandServer.register_command(_handle_request_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
@onready var _cmd_set_time := NetworkCommandServer.register_command(_handle_set_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
## Emitted after the initial time sync.
##
## At the start of the game, clients request an initial timestamp to kickstart
@@ -145,7 +150,7 @@ func start() -> void:
_sample_idx = 0
_sample_buffer = _RingBuffer.new(sync_samples)
_request_timestamp.rpc_id(1)
_cmd_req_time.send(PackedByteArray(), 1)
## Stop the time synchronization loop.
func stop() -> void:
@@ -169,7 +174,7 @@ func _loop() -> void:
_awaiting_samples[_sample_idx] = sample
sample.ping_sent = _clock.get_time()
_send_ping.rpc_id(1, _sample_idx)
_cmd_ping.send(var_to_bytes(_sample_idx), 1)
_sample_idx += 1
@@ -235,15 +240,19 @@ func _discipline_clock() -> void:
_offset = offset - nudge
@rpc("any_peer", "call_remote", "unreliable")
func _send_ping(idx: int) -> void:
func _handle_ping(sender: int, data: PackedByteArray) -> void:
var idx := bytes_to_var(data) as int
var ping_received := _clock.get_time()
var sender := multiplayer.get_remote_sender_id()
_send_pong.rpc_id(sender, idx, ping_received, _clock.get_time())
_cmd_pong.send(var_to_bytes([idx, ping_received, _clock.get_time()]), sender)
func _handle_pong(sender: int, data: PackedByteArray) -> void:
var args := bytes_to_var(data)
var idx := args[0] as int
var ping_received := args[1] as float
var pong_sent := args[2] as float
@rpc("any_peer", "call_remote", "unreliable")
func _send_pong(idx: int, ping_received: float, pong_sent: float) -> void:
var pong_received := _clock.get_time()
if not _awaiting_samples.has(idx):
@@ -264,13 +273,13 @@ func _send_pong(idx: int, ping_received: float, pong_sent: float) -> void:
# Discipline clock based on new sample
_discipline_clock()
@rpc("any_peer", "call_remote", "reliable")
func _request_timestamp() -> void:
func _handle_request_timestamp(sender: int, data: PackedByteArray) -> void:
_logger.debug("Requested initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
_set_timestamp.rpc_id(multiplayer.get_remote_sender_id(), _clock.get_time())
_cmd_set_time.send(var_to_bytes(_clock.get_time()), sender)
func _handle_set_timestamp(sender: int, data: PackedByteArray) -> void:
var timestamp := bytes_to_var(data) as float
@rpc("any_peer", "call_remote", "reliable")
func _set_timestamp(timestamp: float) -> void:
_logger.debug("Received initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
_clock.set_time(timestamp)
_loop()
+9
View File
@@ -467,8 +467,12 @@ func start() -> int:
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)
@@ -559,8 +563,12 @@ 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
@@ -568,6 +576,7 @@ func _loop() -> void:
if ticks_in_loop > 0:
after_tick_loop.emit()
NetworkHistoryServer._restore_synchronizer_state(tick)
func _process(delta: float) -> void:
_process_delta = delta
+1 -1
View File
@@ -3,5 +3,5 @@
name="netfox"
description="Shared internals for netfox addons"
author="Tamas Galffy and contributors"
version="1.35.3"
version="1.40.2"
script="netfox.gd"
@@ -1,28 +0,0 @@
extends _HistoryBuffer
class_name _PropertyHistoryBuffer
func get_snapshot(tick: int) -> _PropertySnapshot:
if _buffer.has(tick):
return _buffer[tick]
else:
return _PropertySnapshot.new()
func set_snapshot(tick: int, data) -> void:
if data is Dictionary:
var snapshot := _PropertySnapshot.from_dictionary(data)
super(tick, snapshot)
elif data is _PropertySnapshot:
super(tick, data)
else:
push_error("Data not a PropertSnapshot! %s" % [data])
func get_history(tick: int) -> _PropertySnapshot:
var snapshot = super(tick)
return snapshot if snapshot else _PropertySnapshot.new()
func trim(earliest_tick_to_keep: int = NetworkRollback.history_start) -> void:
super(earliest_tick_to_keep)
func merge(data: _PropertySnapshot, tick:int) -> void:
set_snapshot(tick, get_snapshot(tick).merge(data))
@@ -1,111 +0,0 @@
extends RefCounted
class_name _RollbackHistoryRecorder
# Provided externally by RBS
var _state_history: _PropertyHistoryBuffer
var _input_history: _PropertyHistoryBuffer
var _state_property_config: _PropertyConfig
var _input_property_config: _PropertyConfig
var _property_cache: PropertyCache
var _latest_state_tick: int
var _skipset: _Set
func configure(
p_state_history: _PropertyHistoryBuffer, p_input_history: _PropertyHistoryBuffer,
p_state_property_config: _PropertyConfig, p_input_property_config: _PropertyConfig,
p_property_cache: PropertyCache,
p_skipset: _Set
) -> void:
_state_history = p_state_history
_input_history = p_input_history
_state_property_config = p_state_property_config
_input_property_config = p_input_property_config
_property_cache = p_property_cache
_skipset = p_skipset
func set_latest_state_tick(p_latest_state_tick: int) -> void:
_latest_state_tick = p_latest_state_tick
func apply_state(tick: int) -> void:
# Apply state for tick
var state = _state_history.get_history(tick)
state.apply(_property_cache)
func apply_display_state() -> void:
apply_state(NetworkRollback.display_tick)
func apply_tick(tick: int) -> void:
var state := _state_history.get_history(tick)
var input := _input_history.get_history(tick)
state.apply(_property_cache)
input.apply(_property_cache)
func trim_history() -> void:
# Trim history
_state_history.trim()
_input_history.trim()
func record_input(tick: int) -> void:
# Record input
if not _get_recorded_input_props().is_empty():
var input = _PropertySnapshot.extract(_get_recorded_input_props())
var input_tick: int = tick + NetworkRollback.input_delay
_input_history.set_snapshot(input_tick, input)
func record_state(tick: int) -> void:
# Record state for specified tick ( current + 1 )
# Check if any of the managed nodes were mutated
var is_mutated := _get_recorded_state_props().any(func(pe):
return NetworkRollback.is_mutated(pe.node, tick - 1))
var record_state := _PropertySnapshot.extract(_get_state_props_to_record(tick))
if record_state.size():
var merge_state := _state_history.get_history(tick - 1)
_state_history.set_snapshot(tick, merge_state.merge(record_state))
func _should_record_tick(tick: int) -> bool:
if _get_recorded_state_props().is_empty():
# Don't record tick if there's no props to record
return false
if _get_recorded_state_props().any(func(pe):
return NetworkRollback.is_mutated(pe.node, tick - 1)):
# If there's any node that was mutated, there's something to record
return true
# Otherwise, record only if we don't have authoritative state for the tick
return tick > _latest_state_tick
func _get_state_props_to_record(tick: int) -> Array[PropertyEntry]:
if not _should_record_tick(tick):
return []
if _skipset.is_empty():
return _get_recorded_state_props()
return _get_recorded_state_props().filter(func(pe): return _should_record_property(pe, tick))
func _should_record_property(property_entry: PropertyEntry, tick: int) -> bool:
if NetworkRollback.is_mutated(property_entry.node, tick - 1):
return true
if _skipset.has(property_entry.node):
return false
return true
# =============================================================================
# Shared utils, extract later
func _get_recorded_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_properties()
func _get_owned_state_props() -> Array[PropertyEntry]:
return _state_property_config.get_owned_properties()
func _get_recorded_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
func _get_owned_input_props() -> Array[PropertyEntry]:
return _input_property_config.get_owned_properties()
@@ -1 +0,0 @@
uid://bn6fsqxbfhihk

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