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:
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
* text=auto eol=lf
|
||||
+19
-14
@@ -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
|
||||
|
||||
@@ -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/`
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -1,91 +0,0 @@
|
||||
Godot Jolt incorporates third-party material from the projects listed below.
|
||||
|
||||
Godot Engine (https://github.com/godotengine/godot)
|
||||
|
||||
Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md).
|
||||
Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
|
||||
godot-cpp (https://github.com/godot-jolt/godot-cpp)
|
||||
|
||||
Copyright (c) 2017-present Godot Engine contributors.
|
||||
Copyright (c) 2022-present Mikael Hermansson.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
|
||||
Jolt Physics (https://github.com/godot-jolt/jolt)
|
||||
|
||||
Copyright (c) 2021 Jorrit Rouwe.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
|
||||
mimalloc (https://github.com/godot-jolt/mimalloc)
|
||||
|
||||
Copyright (c) 2018-2021 Microsoft Corporation, Daan Leijen.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,41 +0,0 @@
|
||||
[godot-jolt]
|
||||
version = "0.16.0-stable"
|
||||
build = "8eca5bb795"
|
||||
|
||||
[configuration]
|
||||
|
||||
entry_symbol = "godot_jolt_main"
|
||||
compatibility_minimum = "4.3"
|
||||
compatibility_maximum = "4.7"
|
||||
|
||||
[libraries]
|
||||
|
||||
windows.release.single.x86_64 = "windows/godot-jolt_windows-x64.dll"
|
||||
windows.debug.single.x86_64 = "windows/godot-jolt_windows-x64_editor.dll"
|
||||
|
||||
windows.release.single.x86_32 = "windows/godot-jolt_windows-x86.dll"
|
||||
windows.debug.single.x86_32 = "windows/godot-jolt_windows-x86_editor.dll"
|
||||
|
||||
linux.release.single.x86_64 = "linux/godot-jolt_linux-x64.so"
|
||||
linux.debug.single.x86_64 = "linux/godot-jolt_linux-x64_editor.so"
|
||||
|
||||
linux.release.single.x86_32 = "linux/godot-jolt_linux-x86.so"
|
||||
linux.debug.single.x86_32 = "linux/godot-jolt_linux-x86_editor.so"
|
||||
|
||||
macos.release.single = "macos/godot-jolt_macos.framework"
|
||||
macos.debug.single = "macos/godot-jolt_macos_editor.framework"
|
||||
|
||||
ios.release.single = "ios/godot-jolt_ios.framework"
|
||||
ios.debug.single = "ios/godot-jolt_ios_editor.framework"
|
||||
|
||||
android.release.single.arm64 = "android/libgodot-jolt_android-arm64.so"
|
||||
android.debug.single.arm64 = "android/libgodot-jolt_android-arm64_editor.so"
|
||||
|
||||
android.release.single.arm32 = "android/libgodot-jolt_android-arm32.so"
|
||||
android.debug.single.arm32 = "android/libgodot-jolt_android-arm32_editor.so"
|
||||
|
||||
android.release.single.x86_64 = "android/libgodot-jolt_android-x64.so"
|
||||
android.debug.single.x86_64 = "android/libgodot-jolt_android-x64_editor.so"
|
||||
|
||||
android.release.single.x86_32 = "android/libgodot-jolt_android-x86.so"
|
||||
android.debug.single.x86_32 = "android/libgodot-jolt_android-x86_editor.so"
|
||||
@@ -1 +0,0 @@
|
||||
uid://bc7tkad04qybu
|
||||
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>godot-jolt_ios</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Godot Jolt</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Godot Jolt</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.godot-jolt.godot-jolt</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.16.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.16.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>iPhoneOS</string>
|
||||
</array>
|
||||
<key>CSResourcesFileMapped</key>
|
||||
<true/>
|
||||
<key>DTPlatformName</key>
|
||||
<string>iphoneos</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.0</string>
|
||||
|
||||
<!--
|
||||
HACK(mihe): This is to work around a bug in Godot 4.3-beta1, where it treats Framework
|
||||
bundles the same as XCFramework bundles, and expects there to be an `AvailableLibraries`
|
||||
entry, which is really only a thing in XCFramework bundles. Note that we also lie about the
|
||||
binary path having a `.dylib` extension in order for Godot to correctly identify this as a
|
||||
dynamically linked bundle.
|
||||
-->
|
||||
<key>AvailableLibraries</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>godot-jolt_ios.dylib</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>godot-jolt_ios_editor</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Godot Jolt</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Godot Jolt</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.godot-jolt.godot-jolt</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.16.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.16.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>iPhoneOS</string>
|
||||
</array>
|
||||
<key>CSResourcesFileMapped</key>
|
||||
<true/>
|
||||
<key>DTPlatformName</key>
|
||||
<string>iphoneos</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>12.0</string>
|
||||
|
||||
<!--
|
||||
HACK(mihe): This is to work around a bug in Godot 4.3-beta1, where it treats Framework
|
||||
bundles the same as XCFramework bundles, and expects there to be an `AvailableLibraries`
|
||||
entry, which is really only a thing in XCFramework bundles. Note that we also lie about the
|
||||
binary path having a `.dylib` extension in order for Godot to correctly identify this as a
|
||||
dynamically linked bundle.
|
||||
-->
|
||||
<key>AvailableLibraries</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>godot-jolt_ios_editor.dylib</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>godot-jolt_macos</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Godot Jolt</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Godot Jolt</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.godot-jolt.godot-jolt</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.16.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.16.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
<key>CSResourcesFileMapped</key>
|
||||
<true/>
|
||||
<key>DTPlatformName</key>
|
||||
<string>macosx</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.12</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,128 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict>
|
||||
<key>Resources/Info.plist</key>
|
||||
<data>
|
||||
PLFiiedGCgBUVIdXAwIQZP3TzhU=
|
||||
</data>
|
||||
</dict>
|
||||
<key>files2</key>
|
||||
<dict>
|
||||
<key>Resources/Info.plist</key>
|
||||
<dict>
|
||||
<key>hash2</key>
|
||||
<data>
|
||||
oUfCierc8a2c8bsFW1mu5lCv8dOCnLWbP/Ju8VP12gk=
|
||||
</data>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^Resources/</key>
|
||||
<true/>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^[^/]+$</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
@@ -1,36 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>godot-jolt_macos_editor</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Godot Jolt</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Godot Jolt</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.godot-jolt.godot-jolt</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright (c) Mikael Hermansson and Godot Jolt contributors.</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.16.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.16.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
<key>CSResourcesFileMapped</key>
|
||||
<true/>
|
||||
<key>DTPlatformName</key>
|
||||
<string>macosx</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.12</string>
|
||||
</dict>
|
||||
</plist>
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict>
|
||||
<key>Resources/Info.plist</key>
|
||||
<data>
|
||||
xxh2HxAbRksJ2pI8miqkcxNuMMY=
|
||||
</data>
|
||||
</dict>
|
||||
<key>files2</key>
|
||||
<dict>
|
||||
<key>Resources/Info.plist</key>
|
||||
<dict>
|
||||
<key>hash2</key>
|
||||
<data>
|
||||
HuUpHzJ0lNjh0+cAW+2ottBM6fM0woHZDn2U83XmNzY=
|
||||
</data>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^Resources/</key>
|
||||
<true/>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^[^/]+$</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,156 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name MCPClient
|
||||
## WebSocket client for communication with the MCP server.
|
||||
## Handles connection, reconnection, and message routing.
|
||||
|
||||
signal connected
|
||||
signal disconnected
|
||||
signal tool_requested(request_id: String, tool_name: String, args: Dictionary)
|
||||
|
||||
const DEFAULT_URL := "ws://127.0.0.1:6505"
|
||||
const RECONNECT_DELAY := 3.0
|
||||
const MAX_RECONNECT_DELAY := 30.0
|
||||
const MAX_PACKETS_PER_FRAME := 32
|
||||
|
||||
var socket: WebSocketPeer = WebSocketPeer.new()
|
||||
var server_url: String = DEFAULT_URL
|
||||
var _is_connected := false
|
||||
var _reconnect_timer: Timer
|
||||
var _current_reconnect_delay := RECONNECT_DELAY
|
||||
var _should_reconnect := true
|
||||
var _project_path: String
|
||||
var _initialized := false
|
||||
|
||||
func _ready() -> void:
|
||||
_project_path = ProjectSettings.globalize_path("res://")
|
||||
|
||||
# Create reconnect timer
|
||||
_reconnect_timer = Timer.new()
|
||||
_reconnect_timer.one_shot = true
|
||||
_reconnect_timer.timeout.connect(_on_reconnect_timer)
|
||||
add_child(_reconnect_timer)
|
||||
_initialized = true
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not _initialized:
|
||||
return
|
||||
if socket.get_ready_state() == WebSocketPeer.STATE_CLOSED:
|
||||
if _is_connected:
|
||||
_handle_disconnect()
|
||||
return
|
||||
|
||||
socket.poll()
|
||||
|
||||
match socket.get_ready_state():
|
||||
WebSocketPeer.STATE_OPEN:
|
||||
if not _is_connected:
|
||||
_handle_connect()
|
||||
var packets_processed := 0
|
||||
while socket.get_available_packet_count() > 0 and packets_processed < MAX_PACKETS_PER_FRAME:
|
||||
_handle_message(socket.get_packet().get_string_from_utf8())
|
||||
packets_processed += 1
|
||||
|
||||
WebSocketPeer.STATE_CLOSING:
|
||||
pass # Wait for close
|
||||
|
||||
WebSocketPeer.STATE_CLOSED:
|
||||
if _is_connected:
|
||||
_handle_disconnect()
|
||||
|
||||
func connect_to_server(url: String = DEFAULT_URL) -> void:
|
||||
server_url = url
|
||||
_should_reconnect = true
|
||||
_current_reconnect_delay = RECONNECT_DELAY
|
||||
_attempt_connection()
|
||||
|
||||
func disconnect_from_server() -> void:
|
||||
_should_reconnect = false
|
||||
if _reconnect_timer:
|
||||
_reconnect_timer.stop()
|
||||
if socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||||
socket.close()
|
||||
_is_connected = false
|
||||
|
||||
func _attempt_connection() -> void:
|
||||
if socket.get_ready_state() != WebSocketPeer.STATE_CLOSED:
|
||||
socket.close()
|
||||
|
||||
print("[MCP] Connecting to ", server_url, "...")
|
||||
var err := socket.connect_to_url(server_url)
|
||||
if err != OK:
|
||||
push_error("[MCP] Failed to connect: ", err)
|
||||
_schedule_reconnect()
|
||||
|
||||
func _handle_connect() -> void:
|
||||
_is_connected = true
|
||||
_current_reconnect_delay = RECONNECT_DELAY # Reset backoff
|
||||
print("[MCP] Connected to server")
|
||||
|
||||
# Send godot_ready message with project info
|
||||
_send_message({
|
||||
&"type": &"godot_ready",
|
||||
&"project_path": _project_path,
|
||||
})
|
||||
|
||||
connected.emit()
|
||||
|
||||
func _handle_disconnect() -> void:
|
||||
_is_connected = false
|
||||
print("[MCP] Disconnected from server")
|
||||
disconnected.emit()
|
||||
|
||||
if _should_reconnect:
|
||||
_schedule_reconnect()
|
||||
|
||||
func _schedule_reconnect() -> void:
|
||||
if not _reconnect_timer:
|
||||
return
|
||||
print("[MCP] Reconnecting in ", _current_reconnect_delay, " seconds...")
|
||||
_reconnect_timer.start(_current_reconnect_delay)
|
||||
# Exponential backoff
|
||||
_current_reconnect_delay = min(_current_reconnect_delay * 2, MAX_RECONNECT_DELAY)
|
||||
|
||||
func _on_reconnect_timer() -> void:
|
||||
_attempt_connection()
|
||||
|
||||
func _handle_message(json_string: String) -> void:
|
||||
var message = JSON.parse_string(json_string)
|
||||
if message == null:
|
||||
push_error("[MCP] Failed to parse message: ", json_string)
|
||||
return
|
||||
|
||||
var msg_type: String = message.get(&"type", "")
|
||||
match msg_type:
|
||||
"ping":
|
||||
_send_message({&"type": &"pong"})
|
||||
"tool_invoke":
|
||||
var request_id: String = message.get(&"id", "")
|
||||
var tool_name: String = message.get(&"tool", "")
|
||||
var args: Dictionary = message.get(&"args", {})
|
||||
print("[MCP] Tool request: ", tool_name, " (", request_id, ")")
|
||||
tool_requested.emit(request_id, tool_name, args)
|
||||
_:
|
||||
print("[MCP] Unknown message type: ", msg_type)
|
||||
|
||||
func send_tool_result(request_id: String, success: bool, result = null, error: String = "") -> void:
|
||||
var response := {
|
||||
&"type": &"tool_result",
|
||||
&"id": request_id,
|
||||
&"success": success,
|
||||
}
|
||||
|
||||
if success:
|
||||
response[&"result"] = result
|
||||
else:
|
||||
response[&"error"] = error
|
||||
|
||||
_send_message(response)
|
||||
print("[MCP] Sent result for ", request_id, " (success=", success, ")")
|
||||
|
||||
func _send_message(message: Dictionary) -> void:
|
||||
if socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||||
socket.send_text(JSON.stringify(message))
|
||||
|
||||
func is_connected_to_server() -> bool:
|
||||
return _is_connected
|
||||
@@ -0,0 +1 @@
|
||||
uid://baiaiot8klmbb
|
||||
@@ -0,0 +1,7 @@
|
||||
[plugin]
|
||||
|
||||
name="Godot MCP"
|
||||
description="MCP server integration for AI assistants. Connects to the godot-mcp-server via WebSocket."
|
||||
author="Godot MCP"
|
||||
version="0.2.6"
|
||||
script="plugin.gd"
|
||||
@@ -0,0 +1,89 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
## Godot MCP Plugin
|
||||
## Connects to the godot-mcp-server via WebSocket and executes tools.
|
||||
|
||||
const MCPClientScript = preload("res://addons/godot_mcp/mcp_client.gd")
|
||||
const ToolExecutorScript = preload("res://addons/godot_mcp/tool_executor.gd")
|
||||
|
||||
var _mcp_client: Node # MCPClient
|
||||
var _tool_executor: Node # ToolExecutor
|
||||
var _status_label: Label
|
||||
|
||||
func _enter_tree() -> void:
|
||||
print("[Godot MCP] Plugin loading...")
|
||||
|
||||
# Create MCP client
|
||||
_mcp_client = MCPClientScript.new()
|
||||
_mcp_client.name = "MCPClient"
|
||||
add_child(_mcp_client)
|
||||
|
||||
# Create tool executor
|
||||
_tool_executor = ToolExecutorScript.new()
|
||||
_tool_executor.name = "ToolExecutor"
|
||||
add_child(_tool_executor) # _ready() runs here, creating child tools
|
||||
_tool_executor.set_editor_plugin(self) # Now _visualizer_tools exists
|
||||
|
||||
# Connect signals
|
||||
_mcp_client.connected.connect(_on_connected)
|
||||
_mcp_client.disconnected.connect(_on_disconnected)
|
||||
_mcp_client.tool_requested.connect(_on_tool_requested)
|
||||
|
||||
# Add status indicator to editor
|
||||
_setup_status_indicator()
|
||||
|
||||
# Start connection
|
||||
_mcp_client.connect_to_server()
|
||||
|
||||
print("[Godot MCP] Plugin loaded - connecting to MCP server...")
|
||||
|
||||
func _exit_tree() -> void:
|
||||
print("[Godot MCP] Plugin unloading...")
|
||||
|
||||
if _mcp_client:
|
||||
_mcp_client.disconnect_from_server()
|
||||
_mcp_client.queue_free()
|
||||
|
||||
if _tool_executor:
|
||||
_tool_executor.queue_free()
|
||||
|
||||
if _status_label:
|
||||
remove_control_from_container(EditorPlugin.CONTAINER_TOOLBAR, _status_label)
|
||||
_status_label.queue_free()
|
||||
|
||||
print("[Godot MCP] Plugin unloaded")
|
||||
|
||||
func _setup_status_indicator() -> void:
|
||||
"""Add a small status label to the editor toolbar."""
|
||||
_status_label = Label.new()
|
||||
_status_label.text = "MCP: Connecting..."
|
||||
_status_label.add_theme_color_override("font_color", Color.YELLOW)
|
||||
_status_label.add_theme_font_size_override("font_size", 12)
|
||||
add_control_to_container(EditorPlugin.CONTAINER_TOOLBAR, _status_label)
|
||||
|
||||
func _on_connected() -> void:
|
||||
print("[Godot MCP] Connected to MCP server")
|
||||
if _status_label:
|
||||
_status_label.text = "MCP: Connected"
|
||||
_status_label.add_theme_color_override("font_color", Color.GREEN)
|
||||
|
||||
func _on_disconnected() -> void:
|
||||
print("[Godot MCP] Disconnected from MCP server")
|
||||
if _status_label:
|
||||
_status_label.text = "MCP: Disconnected"
|
||||
_status_label.add_theme_color_override("font_color", Color.RED)
|
||||
|
||||
func _on_tool_requested(request_id: String, tool_name: String, args: Dictionary) -> void:
|
||||
"""Handle incoming tool request from MCP server."""
|
||||
print("[Godot MCP] Executing tool: ", tool_name)
|
||||
|
||||
# Execute the tool
|
||||
var result: Dictionary = _tool_executor.execute_tool(tool_name, args)
|
||||
|
||||
var success: bool = result.get(&"ok", false)
|
||||
if success:
|
||||
result.erase(&"ok")
|
||||
_mcp_client.send_tool_result(request_id, true, result)
|
||||
else:
|
||||
var error: String = result.get(&"error", "Unknown error")
|
||||
_mcp_client.send_tool_result(request_id, false, null, error)
|
||||
@@ -0,0 +1 @@
|
||||
uid://delieuoth6q2q
|
||||
@@ -0,0 +1,144 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name ToolExecutor
|
||||
## Routes tool invocations to the appropriate handler.
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
var _file_tools: Node
|
||||
var _scene_tools: Node
|
||||
var _script_tools: Node
|
||||
var _project_tools: Node
|
||||
var _asset_tools: Node
|
||||
var _visualizer_tools: Node
|
||||
|
||||
# Tool name → [handler_node, method_name]
|
||||
var _tool_map: Dictionary = {}
|
||||
|
||||
var _initialized := false
|
||||
|
||||
func _init_tools() -> void:
|
||||
"""Initialize all tool handlers. Called from set_editor_plugin."""
|
||||
if _initialized:
|
||||
return
|
||||
_initialized = true
|
||||
|
||||
_file_tools = preload("res://addons/godot_mcp/tools/file_tools.gd").new()
|
||||
_file_tools.name = "FileTools"
|
||||
add_child(_file_tools)
|
||||
|
||||
_scene_tools = preload("res://addons/godot_mcp/tools/scene_tools.gd").new()
|
||||
_scene_tools.name = "SceneTools"
|
||||
add_child(_scene_tools)
|
||||
|
||||
_script_tools = preload("res://addons/godot_mcp/tools/script_tools.gd").new()
|
||||
_script_tools.name = "ScriptTools"
|
||||
add_child(_script_tools)
|
||||
|
||||
_project_tools = preload("res://addons/godot_mcp/tools/project_tools.gd").new()
|
||||
_project_tools.name = "ProjectTools"
|
||||
add_child(_project_tools)
|
||||
|
||||
_asset_tools = preload("res://addons/godot_mcp/tools/asset_tools.gd").new()
|
||||
_asset_tools.name = "AssetTools"
|
||||
add_child(_asset_tools)
|
||||
|
||||
_visualizer_tools = preload("res://addons/godot_mcp/tools/visualizer_tools.gd").new()
|
||||
_visualizer_tools.name = "VisualizerTools"
|
||||
add_child(_visualizer_tools)
|
||||
|
||||
# Build tool routing map
|
||||
_tool_map = {
|
||||
&"list_dir": [_file_tools, &"list_dir"],
|
||||
&"read_file": [_file_tools, &"read_file"],
|
||||
&"search_project": [_file_tools, &"search_project"],
|
||||
&"create_script": [_file_tools, &"create_script"],
|
||||
|
||||
&"create_scene": [_scene_tools, &"create_scene"],
|
||||
&"read_scene": [_scene_tools, &"read_scene"],
|
||||
&"add_node": [_scene_tools, &"add_node"],
|
||||
&"remove_node": [_scene_tools, &"remove_node"],
|
||||
&"modify_node_property": [_scene_tools, &"modify_node_property"],
|
||||
&"rename_node": [_scene_tools, &"rename_node"],
|
||||
&"move_node": [_scene_tools, &"move_node"],
|
||||
&"attach_script": [_scene_tools, &"attach_script"],
|
||||
&"detach_script": [_scene_tools, &"detach_script"],
|
||||
&"set_collision_shape": [_scene_tools, &"set_collision_shape"],
|
||||
&"set_sprite_texture": [_scene_tools, &"set_sprite_texture"],
|
||||
&"get_scene_hierarchy": [_scene_tools, &"get_scene_hierarchy"],
|
||||
&"get_scene_node_properties": [_scene_tools, &"get_scene_node_properties"],
|
||||
&"set_scene_node_property": [_scene_tools, &"set_scene_node_property"],
|
||||
|
||||
&"edit_script": [_script_tools, &"edit_script"],
|
||||
&"validate_script": [_script_tools, &"validate_script"],
|
||||
&"list_scripts": [_script_tools, &"list_scripts"],
|
||||
&"create_folder": [_script_tools, &"create_folder"],
|
||||
&"delete_file": [_script_tools, &"delete_file"],
|
||||
&"rename_file": [_script_tools, &"rename_file"],
|
||||
|
||||
&"get_project_settings": [_project_tools, &"get_project_settings"],
|
||||
&"list_settings": [_project_tools, &"list_settings"],
|
||||
&"update_project_settings": [_project_tools, &"update_project_settings"],
|
||||
&"get_input_map": [_project_tools, &"get_input_map"],
|
||||
&"configure_input_map": [_project_tools, &"configure_input_map"],
|
||||
&"get_collision_layers": [_project_tools, &"get_collision_layers"],
|
||||
&"setup_autoload": [_project_tools, &"setup_autoload"],
|
||||
&"get_node_properties": [_project_tools, &"get_node_properties"],
|
||||
&"get_console_log": [_project_tools, &"get_console_log"],
|
||||
&"get_errors": [_project_tools, &"get_errors"],
|
||||
&"clear_console_log": [_project_tools, &"clear_console_log"],
|
||||
&"open_in_godot": [_project_tools, &"open_in_godot"],
|
||||
&"scene_tree_dump": [_project_tools, &"scene_tree_dump"],
|
||||
|
||||
&"generate_2d_asset": [_asset_tools, &"generate_2d_asset"],
|
||||
|
||||
&"map_project": [_visualizer_tools, &"map_project"],
|
||||
&"map_scenes": [_visualizer_tools, &"map_scenes"],
|
||||
}
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
# Initialize tools first (must be done synchronously)
|
||||
_init_tools()
|
||||
|
||||
# Pass editor plugin reference to all tool handlers
|
||||
if _file_tools: _file_tools.set_editor_plugin(plugin)
|
||||
if _scene_tools: _scene_tools.set_editor_plugin(plugin)
|
||||
if _script_tools: _script_tools.set_editor_plugin(plugin)
|
||||
if _project_tools: _project_tools.set_editor_plugin(plugin)
|
||||
if _asset_tools: _asset_tools.set_editor_plugin(plugin)
|
||||
if _visualizer_tools:
|
||||
_visualizer_tools.set_editor_plugin(plugin)
|
||||
# Pass scene_tools reference for visualizer internal scene functions
|
||||
_visualizer_tools.set_scene_tools_ref(_scene_tools)
|
||||
|
||||
func execute_tool(tool_name: String, args: Dictionary) -> Dictionary:
|
||||
"""Execute a tool by name with the given arguments."""
|
||||
|
||||
# Handle internal visualizer commands (not exposed as MCP tools)
|
||||
if tool_name.begins_with("visualizer._internal_"):
|
||||
var method: String = tool_name.replace("visualizer.", "")
|
||||
if _visualizer_tools and _visualizer_tools.has_method(method):
|
||||
return _visualizer_tools.call(method, args)
|
||||
else:
|
||||
return {&"ok": false, &"error": "Internal method not found: " + method}
|
||||
|
||||
if not _tool_map.has(tool_name):
|
||||
return {
|
||||
&"ok": false,
|
||||
&"error": "Unknown tool: %s. Available: %s" % [tool_name, ", ".join(_tool_map.keys())]
|
||||
}
|
||||
|
||||
var handler: Array = _tool_map[tool_name]
|
||||
var node: Node = handler[0]
|
||||
var method: StringName = handler[1]
|
||||
|
||||
if not node.has_method(method):
|
||||
return {&"ok": false, &"error": "Tool handler not found: %s.%s" % [node.name, method]}
|
||||
|
||||
return node.call(method, args)
|
||||
|
||||
func get_available_tools() -> Array:
|
||||
"""Return list of available tool names."""
|
||||
return _tool_map.keys()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtvhy5tkh2hjv
|
||||
@@ -0,0 +1,135 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name AssetTools
|
||||
## Asset generation tools for MCP.
|
||||
## Handles: generate_2d_asset, search_comfyui_nodes,
|
||||
## inspect_runninghub_workflow, customize_and_run_workflow
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
func _refresh_filesystem() -> void:
|
||||
if _editor_plugin:
|
||||
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
|
||||
|
||||
# =============================================================================
|
||||
# generate_2d_asset - Generate PNG from SVG code
|
||||
# =============================================================================
|
||||
func generate_2d_asset(args: Dictionary) -> Dictionary:
|
||||
var svg_code: String = str(args.get(&"svg_code", ""))
|
||||
var filename: String = str(args.get(&"filename", ""))
|
||||
var save_path: String = str(args.get(&"save_path", "res://assets/generated/"))
|
||||
|
||||
if svg_code.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'svg_code'"}
|
||||
if filename.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'filename'"}
|
||||
|
||||
# Ensure .png extension
|
||||
if not filename.ends_with(".png"):
|
||||
filename += ".png"
|
||||
|
||||
# Ensure save path
|
||||
if not save_path.begins_with("res://"):
|
||||
save_path = "res://" + save_path
|
||||
if not save_path.ends_with("/"):
|
||||
save_path += "/"
|
||||
|
||||
# Create directory if needed
|
||||
if not DirAccess.dir_exists_absolute(save_path):
|
||||
DirAccess.make_dir_recursive_absolute(save_path)
|
||||
|
||||
# Parse SVG dimensions from the svg_code
|
||||
var width := 64
|
||||
var height := 64
|
||||
|
||||
# Simple regex-free parsing for width/height
|
||||
var w_start := svg_code.find("width=\"")
|
||||
if w_start != -1:
|
||||
var w_val := svg_code.substr(w_start + 7)
|
||||
var w_end := w_val.find("\"")
|
||||
if w_end != -1:
|
||||
width = int(w_val.substr(0, w_end))
|
||||
|
||||
var h_start := svg_code.find("height=\"")
|
||||
if h_start != -1:
|
||||
var h_val := svg_code.substr(h_start + 8)
|
||||
var h_end := h_val.find("\"")
|
||||
if h_end != -1:
|
||||
height = int(h_val.substr(0, h_end))
|
||||
|
||||
# Create Image from SVG
|
||||
var image := Image.new()
|
||||
|
||||
# Save SVG to temp file, then load as image
|
||||
var temp_svg_path := "user://temp_asset.svg"
|
||||
var svg_file := FileAccess.open(temp_svg_path, FileAccess.WRITE)
|
||||
if not svg_file:
|
||||
return {&"ok": false, &"error": "Failed to create temp SVG file"}
|
||||
svg_file.store_string(svg_code)
|
||||
svg_file.close()
|
||||
|
||||
# Load SVG as image
|
||||
var err := image.load(temp_svg_path)
|
||||
if err != OK:
|
||||
# Fallback: try loading SVG data directly
|
||||
image = Image.create(width, height, false, Image.FORMAT_RGBA8)
|
||||
image.fill(Color(1, 0, 1, 1)) # Magenta fallback = something went wrong
|
||||
print("[MCP] Warning: Could not render SVG, created fallback image")
|
||||
|
||||
# Clean up temp file
|
||||
DirAccess.remove_absolute(temp_svg_path)
|
||||
|
||||
# Save as PNG
|
||||
var full_path := save_path + filename
|
||||
var global_path := ProjectSettings.globalize_path(full_path)
|
||||
err = image.save_png(global_path)
|
||||
if err != OK:
|
||||
return {&"ok": false, &"error": "Failed to save PNG: " + str(err)}
|
||||
|
||||
_refresh_filesystem()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"resource_path": full_path,
|
||||
&"dimensions": {&"width": width, &"height": height},
|
||||
&"message": "Generated %s (%dx%d)" % [full_path, width, height],
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# search_comfyui_nodes - Stub (requires external database)
|
||||
# =============================================================================
|
||||
func search_comfyui_nodes(args: Dictionary) -> Dictionary:
|
||||
# This tool requires the ComfyUI node database which was bundled with the old plugin
|
||||
# For now, return a message indicating it needs setup
|
||||
return {
|
||||
&"ok": true,
|
||||
&"results": [],
|
||||
&"count": 0,
|
||||
&"message": "ComfyUI node search requires the node database. This feature will be available in a future update.",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# inspect_runninghub_workflow - Stub (requires API key)
|
||||
# =============================================================================
|
||||
func inspect_runninghub_workflow(args: Dictionary) -> Dictionary:
|
||||
var workflow_id: String = str(args.get(&"workflow_id", ""))
|
||||
if workflow_id.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'workflow_id'"}
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"workflow_id": workflow_id,
|
||||
&"message": "RunningHub workflow inspection requires API configuration. This feature will be available in a future update.",
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# customize_and_run_workflow - Stub (requires API key)
|
||||
# =============================================================================
|
||||
func customize_and_run_workflow(args: Dictionary) -> Dictionary:
|
||||
return {
|
||||
&"ok": true,
|
||||
&"message": "RunningHub workflow execution requires API configuration. This feature will be available in a future update.",
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://br1exnegw426j
|
||||
@@ -0,0 +1,292 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name FileTools
|
||||
## File operation tools for MCP.
|
||||
## Handles: list_dir, read_file, search_project, create_script
|
||||
|
||||
const DEFAULT_MAX_BYTES := 200_000
|
||||
const DEFAULT_MAX_RESULTS := 200
|
||||
const MAX_TRAVERSAL_DEPTH := 20
|
||||
const _SKIP_EXTENSIONS: Dictionary = {
|
||||
".import": true, ".png": true, ".jpg": true, ".jpeg": true,
|
||||
".webp": true, ".svg": true, ".ogg": true, ".wav": true,
|
||||
".mp3": true, ".escn": true, ".glb": true, ".gltf": true,
|
||||
".uid": true,
|
||||
}
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
# =============================================================================
|
||||
# list_dir - List files and folders in a directory
|
||||
# =============================================================================
|
||||
func list_dir(args: Dictionary) -> Dictionary:
|
||||
var root: String = str(args.get(&"root", "res://"))
|
||||
var include_hidden: bool = bool(args.get(&"include_hidden", false))
|
||||
|
||||
if not root.begins_with("res://"):
|
||||
root = "res://" + root
|
||||
|
||||
var dir := DirAccess.open(root)
|
||||
if dir == null:
|
||||
return {&"ok": false, &"error": "Cannot open directory: " + root}
|
||||
|
||||
var files: PackedStringArray = []
|
||||
var folders: PackedStringArray = []
|
||||
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while name != "":
|
||||
# Skip hidden files unless requested
|
||||
if not include_hidden and name.begins_with("."):
|
||||
name = dir.get_next()
|
||||
continue
|
||||
|
||||
# Skip .uid files
|
||||
if name.ends_with(".uid"):
|
||||
name = dir.get_next()
|
||||
continue
|
||||
|
||||
if dir.current_is_dir():
|
||||
folders.append(name)
|
||||
else:
|
||||
files.append(name)
|
||||
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
# Sort alphabetically
|
||||
files.sort()
|
||||
folders.sort()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"path": root,
|
||||
&"files": files,
|
||||
&"folders": folders,
|
||||
&"total": files.size() + folders.size()
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# read_file - Read contents of a file
|
||||
# =============================================================================
|
||||
func read_file(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
var start_line: int = int(args.get(&"start_line", 1))
|
||||
var end_line: int = int(args.get(&"end_line", 0))
|
||||
var max_bytes: int = int(args.get(&"max_bytes", DEFAULT_MAX_BYTES))
|
||||
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path' parameter"}
|
||||
|
||||
if not path.begins_with("res://"):
|
||||
path = "res://" + path
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File not found: " + path}
|
||||
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
return {&"ok": false, &"error": "Cannot open file: " + path}
|
||||
|
||||
var content: String
|
||||
var line_count: int = 0
|
||||
|
||||
# If no line range specified, read up to max_bytes
|
||||
if end_line <= 0 and start_line <= 1:
|
||||
var size := mini(max_bytes, file.get_length())
|
||||
content = file.get_buffer(size).get_string_from_utf8()
|
||||
# Count lines
|
||||
line_count = content.count("\n") + 1
|
||||
else:
|
||||
# Read specific line range
|
||||
var lines: Array = []
|
||||
var current_line := 0
|
||||
var total_bytes := 0
|
||||
|
||||
while not file.eof_reached():
|
||||
var line := file.get_line()
|
||||
current_line += 1
|
||||
|
||||
if current_line < start_line:
|
||||
continue
|
||||
if end_line > 0 and current_line > end_line:
|
||||
break
|
||||
|
||||
lines.append(line)
|
||||
total_bytes += line.length() + 1 # +1 for newline
|
||||
|
||||
if total_bytes > max_bytes:
|
||||
break
|
||||
|
||||
content = "\n".join(lines)
|
||||
line_count = lines.size()
|
||||
|
||||
file.close()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"path": path,
|
||||
&"content": content,
|
||||
&"line_count": line_count,
|
||||
&"range": [start_line, end_line] if end_line > 0 else null
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# search_project - Search for text in project files
|
||||
# =============================================================================
|
||||
func search_project(args: Dictionary) -> Dictionary:
|
||||
var query: String = str(args.get(&"query", ""))
|
||||
var glob_filter: String = str(args.get(&"glob", ""))
|
||||
var max_results: int = int(args.get(&"max_results", DEFAULT_MAX_RESULTS))
|
||||
var case_sensitive: bool = bool(args.get(&"case_sensitive", false))
|
||||
|
||||
if query.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'query' parameter"}
|
||||
|
||||
var search_query := query if case_sensitive else query.to_lower()
|
||||
var files := _collect_files("res://", glob_filter)
|
||||
var matches: Array = []
|
||||
|
||||
for file_path: String in files:
|
||||
if matches.size() >= max_results:
|
||||
break
|
||||
|
||||
var file := FileAccess.open(file_path, FileAccess.READ)
|
||||
if file == null:
|
||||
continue
|
||||
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
var search_content := content if case_sensitive else content.to_lower()
|
||||
if search_content.find(search_query) == -1:
|
||||
continue
|
||||
|
||||
var lines := content.split("\n")
|
||||
for i: int in range(lines.size()):
|
||||
var line := lines[i]
|
||||
var search_line := line if case_sensitive else line.to_lower()
|
||||
if search_line.find(search_query) != -1:
|
||||
matches.append({
|
||||
&"file": file_path,
|
||||
&"line": i + 1,
|
||||
&"content": line.strip_edges()
|
||||
})
|
||||
if matches.size() >= max_results:
|
||||
break
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"query": query,
|
||||
&"matches": matches,
|
||||
&"total_matches": matches.size(),
|
||||
&"truncated": matches.size() >= max_results
|
||||
}
|
||||
|
||||
func _collect_files(path: String, glob_filter: String) -> PackedStringArray:
|
||||
"""Recursively collect all searchable files."""
|
||||
var result: PackedStringArray = []
|
||||
_collect_files_recursive(path, glob_filter, result)
|
||||
return result
|
||||
|
||||
func _collect_files_recursive(path: String, glob_filter: String, out: PackedStringArray, depth: int = 0) -> void:
|
||||
if depth >= MAX_TRAVERSAL_DEPTH:
|
||||
return
|
||||
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while name != "":
|
||||
# Skip hidden
|
||||
if name.begins_with("."):
|
||||
name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(name)
|
||||
|
||||
if dir.current_is_dir():
|
||||
_collect_files_recursive(full_path, glob_filter, out, depth + 1)
|
||||
else:
|
||||
var ext := "." + name.get_extension().to_lower()
|
||||
if not _SKIP_EXTENSIONS.has(ext):
|
||||
if glob_filter.is_empty() or _matches_glob(full_path, glob_filter):
|
||||
out.append(full_path)
|
||||
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
func _matches_glob(path: String, pattern: String) -> bool:
|
||||
"""Simple glob matching: *.gd, **/*.tscn, etc."""
|
||||
# Handle **/*.ext pattern
|
||||
if pattern.begins_with("**/"):
|
||||
var ext := pattern.substr(3) # Remove **/
|
||||
return path.ends_with(ext.replace("*", ""))
|
||||
|
||||
# Handle *.ext pattern
|
||||
if pattern.begins_with("*."):
|
||||
return path.ends_with(pattern.substr(1))
|
||||
|
||||
# Simple contains check
|
||||
return path.find(pattern) != -1
|
||||
|
||||
# =============================================================================
|
||||
# create_script - Create a new GDScript file
|
||||
# =============================================================================
|
||||
func create_script(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
var content: String = str(args.get(&"content", ""))
|
||||
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path' parameter"}
|
||||
|
||||
if not path.begins_with("res://"):
|
||||
path = "res://" + path
|
||||
|
||||
# Add .gd extension if missing
|
||||
if not "." in path.get_file():
|
||||
path += ".gd"
|
||||
|
||||
# Check if file already exists
|
||||
if FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File already exists: " + path}
|
||||
|
||||
# Ensure parent directory exists
|
||||
var dir_path := path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
var err := DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
if err != OK:
|
||||
return {&"ok": false, &"error": "Could not create directory: " + dir_path}
|
||||
|
||||
# Write file
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return {&"ok": false, &"error": "Could not create file: " + path}
|
||||
|
||||
file.store_string(content)
|
||||
file.close()
|
||||
|
||||
# Refresh filesystem so Godot sees the new file
|
||||
_refresh_filesystem()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"path": path,
|
||||
&"size_bytes": content.length(),
|
||||
&"message": "Script created successfully"
|
||||
}
|
||||
|
||||
func _refresh_filesystem() -> void:
|
||||
"""Tell Godot to rescan the filesystem."""
|
||||
if _editor_plugin != null:
|
||||
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
|
||||
elif Engine.is_editor_hint():
|
||||
# Fallback if no plugin reference
|
||||
var editor_interface = Engine.get_singleton("EditorInterface")
|
||||
if editor_interface:
|
||||
editor_interface.get_resource_filesystem().scan()
|
||||
@@ -0,0 +1 @@
|
||||
uid://fqjgqcv4hnjn
|
||||
@@ -0,0 +1,744 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name ProjectTools
|
||||
## Project configuration and debug tools for MCP.
|
||||
## Handles: get_project_settings, list_settings, update_project_settings,
|
||||
## get_input_map, configure_input_map, get_collision_layers,
|
||||
## get_node_properties, setup_autoload,
|
||||
## get_console_log, get_errors, clear_console_log,
|
||||
## open_in_godot, scene_tree_dump
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
# Cached reference to the editor Output panel's RichTextLabel.
|
||||
var _editor_log_rtl: RichTextLabel = null
|
||||
|
||||
# Character offset for clear_console_log.
|
||||
var _clear_char_offset: int = 0
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
# =============================================================================
|
||||
# get_project_settings
|
||||
# =============================================================================
|
||||
func get_project_settings(args: Dictionary) -> Dictionary:
|
||||
var include_render: bool = bool(args.get(&"include_render", true))
|
||||
var include_physics: bool = bool(args.get(&"include_physics", true))
|
||||
|
||||
var out: Dictionary = {}
|
||||
out[&"main_scene"] = str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
||||
|
||||
# Window size
|
||||
var width = ProjectSettings.get_setting("display/window/size/viewport_width", null)
|
||||
var height = ProjectSettings.get_setting("display/window/size/viewport_height", null)
|
||||
if width != null: out[&"window_width"] = int(width)
|
||||
if height != null: out[&"window_height"] = int(height)
|
||||
|
||||
# Stretch
|
||||
var stretch_mode = ProjectSettings.get_setting("display/window/stretch/mode", null)
|
||||
var stretch_aspect = ProjectSettings.get_setting("display/window/stretch/aspect", null)
|
||||
if stretch_mode != null: out[&"stretch_mode"] = str(stretch_mode)
|
||||
if stretch_aspect != null: out[&"stretch_aspect"] = str(stretch_aspect)
|
||||
|
||||
if include_physics:
|
||||
var pps = ProjectSettings.get_setting("physics/common/physics_ticks_per_second", null)
|
||||
if pps != null: out[&"physics_ticks_per_second"] = int(pps)
|
||||
|
||||
if include_render:
|
||||
var method = ProjectSettings.get_setting("rendering/renderer/rendering_method", null)
|
||||
if method != null: out[&"rendering_method"] = str(method)
|
||||
var vsync = ProjectSettings.get_setting("display/window/vsync/vsync_mode", null)
|
||||
if vsync != null: out[&"vsync"] = str(vsync)
|
||||
|
||||
return {&"ok": true, &"settings": out}
|
||||
|
||||
# =============================================================================
|
||||
# list_settings
|
||||
# =============================================================================
|
||||
func list_settings(args: Dictionary) -> Dictionary:
|
||||
var category: String = str(args.get(&"category", ""))
|
||||
|
||||
var properties: Array = ProjectSettings.get_property_list()
|
||||
|
||||
if category.strip_edges().is_empty():
|
||||
var categories: Dictionary = {}
|
||||
for prop: Dictionary in properties:
|
||||
var prop_name: String = prop[&"name"]
|
||||
if prop_name.is_empty() or prop_name.begins_with("_"):
|
||||
continue
|
||||
var slash_idx := prop_name.find("/")
|
||||
if slash_idx == -1:
|
||||
continue
|
||||
var cat: String = prop_name.substr(0, slash_idx)
|
||||
categories[cat] = categories.get(cat, 0) + 1
|
||||
return {&"ok": true, &"categories": categories,
|
||||
&"hint": "Pass a category name to list its settings with current values and valid options."}
|
||||
|
||||
var settings: Array = []
|
||||
for prop: Dictionary in properties:
|
||||
var prop_name: String = prop[&"name"]
|
||||
if not prop_name.begins_with(category + "/"):
|
||||
continue
|
||||
if prop_name.begins_with("_"):
|
||||
continue
|
||||
|
||||
var info: Dictionary = {
|
||||
&"path": prop_name,
|
||||
&"type": _type_to_string(prop[&"type"]),
|
||||
&"value": _serialize_value(ProjectSettings.get_setting(prop_name))
|
||||
}
|
||||
|
||||
var hint: int = prop.get(&"hint", 0)
|
||||
var hint_string: String = str(prop.get(&"hint_string", ""))
|
||||
if hint == PROPERTY_HINT_ENUM and not hint_string.is_empty():
|
||||
info[&"enum_values"] = hint_string
|
||||
elif hint == PROPERTY_HINT_RANGE and not hint_string.is_empty():
|
||||
info[&"range"] = hint_string
|
||||
|
||||
settings.append(info)
|
||||
|
||||
return {&"ok": true, &"category": category, &"settings": settings, &"count": settings.size()}
|
||||
|
||||
# =============================================================================
|
||||
# update_project_settings
|
||||
# =============================================================================
|
||||
func update_project_settings(args: Dictionary) -> Dictionary:
|
||||
var settings = args.get(&"settings", {})
|
||||
if not settings is Dictionary or settings.is_empty():
|
||||
return {&"ok": false, &"error": "Missing or empty 'settings' dictionary. Use list_settings to discover available setting paths."}
|
||||
|
||||
var updated: Array = []
|
||||
for key: String in settings:
|
||||
ProjectSettings.set_setting(key, settings[key])
|
||||
updated.append(key)
|
||||
|
||||
_save_and_refresh_settings()
|
||||
return {&"ok": true, &"updated": updated, &"count": updated.size()}
|
||||
|
||||
# =============================================================================
|
||||
# get_input_map
|
||||
# =============================================================================
|
||||
func get_input_map(args: Dictionary) -> Dictionary:
|
||||
var include_deadzones: bool = bool(args.get(&"include_deadzones", true))
|
||||
var actions: Array = InputMap.get_actions()
|
||||
actions.sort()
|
||||
|
||||
var result: Dictionary = {}
|
||||
for action: StringName in actions:
|
||||
var events: Array = []
|
||||
for e: InputEvent in InputMap.action_get_events(action):
|
||||
var item := {&"type": e.get_class()}
|
||||
|
||||
if e is InputEventKey:
|
||||
var keycode = e.physical_keycode if e.physical_keycode != 0 else e.keycode
|
||||
item[&"keycode"] = keycode
|
||||
item[&"key_label"] = OS.get_keycode_string(keycode) if keycode != 0 else ""
|
||||
elif e is InputEventMouseButton:
|
||||
item[&"button_index"] = e.button_index
|
||||
elif e is InputEventJoypadButton:
|
||||
item[&"button_index"] = e.button_index
|
||||
elif e is InputEventJoypadMotion:
|
||||
item[&"axis"] = e.axis
|
||||
if include_deadzones:
|
||||
item[&"axis_value"] = e.axis_value
|
||||
|
||||
events.append(item)
|
||||
result[action] = events
|
||||
|
||||
return {&"ok": true, &"actions": result, &"count": result.size()}
|
||||
|
||||
# =============================================================================
|
||||
# configure_input_map
|
||||
# =============================================================================
|
||||
func configure_input_map(args: Dictionary) -> Dictionary:
|
||||
var action: String = str(args.get(&"action", ""))
|
||||
var operation: String = str(args.get(&"operation", ""))
|
||||
|
||||
if action.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'action' name"}
|
||||
if operation.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'operation'. Use: add, remove, set"}
|
||||
|
||||
match operation:
|
||||
"add":
|
||||
return _input_map_add(action, args)
|
||||
"remove":
|
||||
return _input_map_remove(action)
|
||||
"set":
|
||||
return _input_map_set(action, args)
|
||||
_:
|
||||
return {&"ok": false, &"error": "Unknown operation: %s. Use: add, remove, set" % operation}
|
||||
|
||||
func _input_map_add(action: String, args: Dictionary) -> Dictionary:
|
||||
var deadzone: float = float(args.get(&"deadzone", 0.5))
|
||||
var events_data: Array = args.get(&"events", [])
|
||||
|
||||
var created := false
|
||||
if not InputMap.has_action(action):
|
||||
InputMap.add_action(action, deadzone)
|
||||
created = true
|
||||
|
||||
var added_events: Array = []
|
||||
var event_errors: Array = []
|
||||
for event_desc in events_data:
|
||||
if not event_desc is Dictionary:
|
||||
continue
|
||||
var result: Dictionary = _create_input_event(event_desc)
|
||||
if result.has(&"error"):
|
||||
event_errors.append(result[&"error"])
|
||||
continue
|
||||
InputMap.action_add_event(action, result[&"event"])
|
||||
added_events.append(_describe_event(result[&"event"]))
|
||||
|
||||
_persist_action(action)
|
||||
_save_and_refresh_settings()
|
||||
_try_refresh_input_map_ui()
|
||||
|
||||
var msg := "Action '%s' %s" % [action, "created" if created else "updated"]
|
||||
if added_events.size() > 0:
|
||||
msg += " with %d event(s)" % added_events.size()
|
||||
|
||||
var out: Dictionary = {&"ok": true, &"message": msg, &"events_added": added_events}
|
||||
if event_errors.size() > 0:
|
||||
out[&"event_errors"] = event_errors
|
||||
return out
|
||||
|
||||
func _input_map_remove(action: String) -> Dictionary:
|
||||
if not InputMap.has_action(action):
|
||||
return {&"ok": false, &"error": "Action not found: " + action}
|
||||
|
||||
InputMap.erase_action(action)
|
||||
if ProjectSettings.has_setting("input/" + action):
|
||||
ProjectSettings.clear("input/" + action)
|
||||
_save_and_refresh_settings()
|
||||
_try_refresh_input_map_ui()
|
||||
|
||||
return {&"ok": true, &"message": "Removed action: " + action}
|
||||
|
||||
func _input_map_set(action: String, args: Dictionary) -> Dictionary:
|
||||
var deadzone: float = float(args.get(&"deadzone", 0.5))
|
||||
var events_data: Array = args.get(&"events", [])
|
||||
|
||||
if InputMap.has_action(action):
|
||||
InputMap.erase_action(action)
|
||||
|
||||
InputMap.add_action(action, deadzone)
|
||||
|
||||
var added_events: Array = []
|
||||
var event_errors: Array = []
|
||||
for event_desc in events_data:
|
||||
if not event_desc is Dictionary:
|
||||
continue
|
||||
var result: Dictionary = _create_input_event(event_desc)
|
||||
if result.has(&"error"):
|
||||
event_errors.append(result[&"error"])
|
||||
continue
|
||||
InputMap.action_add_event(action, result[&"event"])
|
||||
added_events.append(_describe_event(result[&"event"]))
|
||||
|
||||
_persist_action(action)
|
||||
_save_and_refresh_settings()
|
||||
_try_refresh_input_map_ui()
|
||||
|
||||
var out: Dictionary = {&"ok": true, &"message": "Set action '%s' with %d event(s)" % [action, added_events.size()], &"events": added_events}
|
||||
if event_errors.size() > 0:
|
||||
out[&"event_errors"] = event_errors
|
||||
return out
|
||||
|
||||
func _create_input_event(desc: Dictionary) -> Dictionary:
|
||||
var type: String = str(desc.get(&"type", ""))
|
||||
|
||||
match type:
|
||||
"key":
|
||||
var key_string: String = str(desc.get(&"key", ""))
|
||||
if key_string.is_empty():
|
||||
return {&"error": "Missing 'key' for key event"}
|
||||
var event := InputEventKey.new()
|
||||
var keycode := OS.find_keycode_from_string(key_string)
|
||||
if keycode == 0:
|
||||
return {&"error": "Unknown key: " + key_string}
|
||||
event.physical_keycode = keycode
|
||||
return {&"event": event}
|
||||
|
||||
"mouse_button":
|
||||
var button_index: int = int(desc.get(&"button_index", 0))
|
||||
if button_index <= 0:
|
||||
return {&"error": "Invalid 'button_index' for mouse_button (must be >= 1: 1=left, 2=right, 3=middle)"}
|
||||
var event := InputEventMouseButton.new()
|
||||
event.button_index = button_index
|
||||
return {&"event": event}
|
||||
|
||||
"joypad_button":
|
||||
var button_index: int = int(desc.get(&"button_index", -1))
|
||||
if button_index < 0:
|
||||
return {&"error": "Missing or invalid 'button_index' for joypad_button"}
|
||||
var event := InputEventJoypadButton.new()
|
||||
event.button_index = button_index
|
||||
return {&"event": event}
|
||||
|
||||
"joypad_motion":
|
||||
var axis: int = int(desc.get(&"axis", -1))
|
||||
if axis < 0:
|
||||
return {&"error": "Missing or invalid 'axis' for joypad_motion"}
|
||||
var axis_value: float = float(desc.get(&"axis_value", 0.0))
|
||||
var event := InputEventJoypadMotion.new()
|
||||
event.axis = axis
|
||||
event.axis_value = axis_value
|
||||
return {&"event": event}
|
||||
|
||||
_:
|
||||
return {&"error": "Unknown event type: '%s'. Use: key, mouse_button, joypad_button, joypad_motion" % type}
|
||||
|
||||
func _save_and_refresh_settings() -> void:
|
||||
ProjectSettings.save()
|
||||
ProjectSettings.notify_property_list_changed()
|
||||
|
||||
func _try_refresh_input_map_ui() -> void:
|
||||
if not _editor_plugin:
|
||||
return
|
||||
var base := _editor_plugin.get_editor_interface().get_base_control()
|
||||
var pse := _find_node_by_class(base, "ProjectSettingsEditor")
|
||||
if not pse:
|
||||
return
|
||||
if pse.has_method("_update_action_map_editor"):
|
||||
pse.call("_update_action_map_editor")
|
||||
else:
|
||||
push_warning("[Godot MCP] Input map changed and saved, but the editor UI could not refresh. Reopen Project Settings to see changes.")
|
||||
|
||||
func _persist_action(action: String) -> void:
|
||||
if not InputMap.has_action(action):
|
||||
return
|
||||
var deadzone: float = InputMap.action_get_deadzone(action)
|
||||
var events: Array = InputMap.action_get_events(action)
|
||||
ProjectSettings.set_setting("input/" + action, {
|
||||
"deadzone": deadzone,
|
||||
"events": events
|
||||
})
|
||||
|
||||
func _describe_event(event: InputEvent) -> String:
|
||||
if event is InputEventKey:
|
||||
var keycode: int = event.physical_keycode if event.physical_keycode != 0 else event.keycode
|
||||
var label: String = OS.get_keycode_string(keycode) if keycode != 0 else "Unknown"
|
||||
return "Key: " + label
|
||||
elif event is InputEventMouseButton:
|
||||
return "Mouse Button: " + str(event.button_index)
|
||||
elif event is InputEventJoypadButton:
|
||||
return "Joypad Button: " + str(event.button_index)
|
||||
elif event is InputEventJoypadMotion:
|
||||
return "Joypad Axis: %d (%.1f)" % [event.axis, event.axis_value]
|
||||
return event.get_class()
|
||||
|
||||
# =============================================================================
|
||||
# get_collision_layers
|
||||
# =============================================================================
|
||||
func get_collision_layers(_args: Dictionary) -> Dictionary:
|
||||
var layers_2d: Array = _collect_layers("layer_names/2d_physics")
|
||||
var layers_3d: Array = _collect_layers("layer_names/3d_physics")
|
||||
return {&"ok": true, &"layers_2d": layers_2d, &"layers_3d": layers_3d}
|
||||
|
||||
func _collect_layers(prefix: String) -> Array:
|
||||
var out: Array = []
|
||||
for i: int in range(1, 33):
|
||||
var key := "%s/layer_%d" % [prefix, i]
|
||||
var layer_name := str(ProjectSettings.get_setting(key, ""))
|
||||
if not layer_name.is_empty():
|
||||
out.append({&"index": i, &"name": layer_name})
|
||||
return out
|
||||
|
||||
# =============================================================================
|
||||
# get_node_properties
|
||||
# =============================================================================
|
||||
const _SKIP_PROPS: Dictionary = {
|
||||
"script": true, "owner": true, "scene_file_path": true, "unique_name_in_owner": true,
|
||||
}
|
||||
|
||||
const ENUM_HINTS = {
|
||||
"anchors_preset": "0:Top Left,1:Top Right,2:Bottom Right,3:Bottom Left,4:Center Left,5:Center Top,6:Center Right,7:Center Bottom,8:Center,9:Left Wide,10:Top Wide,11:Right Wide,12:Bottom Wide,13:VCenter Wide,14:HCenter Wide,15:Full Rect",
|
||||
"grow_horizontal": "0:Begin,1:End,2:Both",
|
||||
"grow_vertical": "0:Begin,1:End,2:Both",
|
||||
"horizontal_alignment": "0:Left,1:Center,2:Right,3:Fill",
|
||||
"vertical_alignment": "0:Top,1:Center,2:Bottom,3:Fill"
|
||||
}
|
||||
|
||||
func get_node_properties(args: Dictionary) -> Dictionary:
|
||||
var node_type: String = str(args.get(&"node_type", ""))
|
||||
if node_type.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'node_type'"}
|
||||
if not ClassDB.class_exists(node_type):
|
||||
return {&"ok": false, &"error": "Unknown node type: " + node_type}
|
||||
|
||||
var temp = ClassDB.instantiate(node_type)
|
||||
if not temp:
|
||||
return {&"ok": false, &"error": "Cannot instantiate: " + node_type}
|
||||
|
||||
var properties: Array = []
|
||||
for prop: Dictionary in temp.get_property_list():
|
||||
var prop_name: String = prop[&"name"]
|
||||
if prop_name.begins_with("_"):
|
||||
continue
|
||||
if _SKIP_PROPS.has(prop_name):
|
||||
continue
|
||||
if not (prop.get(&"usage", 0) & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
|
||||
var info := {
|
||||
&"name": prop_name,
|
||||
&"type": _type_to_string(prop[&"type"]),
|
||||
&"default": _serialize_value(temp.get(prop_name))
|
||||
}
|
||||
|
||||
# Enum hints
|
||||
if prop.has(&"hint") and prop[&"hint"] == PROPERTY_HINT_ENUM and prop.has(&"hint_string"):
|
||||
info[&"enum_values"] = prop[&"hint_string"]
|
||||
if prop_name in ENUM_HINTS:
|
||||
info[&"enum_values"] = ENUM_HINTS[prop_name]
|
||||
|
||||
properties.append(info)
|
||||
|
||||
temp.queue_free()
|
||||
|
||||
# Inheritance chain
|
||||
var chain: Array = []
|
||||
var cls: String = node_type
|
||||
while cls != "":
|
||||
chain.append(cls)
|
||||
cls = ClassDB.get_parent_class(cls)
|
||||
|
||||
return {&"ok": true, &"node_type": node_type, &"inheritance_chain": chain,
|
||||
&"property_count": properties.size(), &"properties": properties}
|
||||
|
||||
func _type_to_string(type_id: int) -> String:
|
||||
match type_id:
|
||||
TYPE_BOOL: return "bool"
|
||||
TYPE_INT: return "int"
|
||||
TYPE_FLOAT: return "float"
|
||||
TYPE_STRING: return "String"
|
||||
TYPE_VECTOR2: return "Vector2"
|
||||
TYPE_VECTOR3: return "Vector3"
|
||||
TYPE_VECTOR2I: return "Vector2i"
|
||||
TYPE_VECTOR3I: return "Vector3i"
|
||||
TYPE_COLOR: return "Color"
|
||||
TYPE_RECT2: return "Rect2"
|
||||
TYPE_OBJECT: return "Resource"
|
||||
TYPE_ARRAY: return "Array"
|
||||
TYPE_DICTIONARY: return "Dictionary"
|
||||
_: return "Variant"
|
||||
|
||||
func _serialize_value(value: Variant) -> Variant:
|
||||
match typeof(value):
|
||||
TYPE_VECTOR2: return {&"type": &"Vector2", &"x": value.x, &"y": value.y}
|
||||
TYPE_VECTOR3: return {&"type": &"Vector3", &"x": value.x, &"y": value.y, &"z": value.z}
|
||||
TYPE_COLOR: return {&"type": &"Color", &"r": value.r, &"g": value.g, &"b": value.b, &"a": value.a}
|
||||
TYPE_VECTOR2I: return {&"type": &"Vector2i", &"x": value.x, &"y": value.y}
|
||||
TYPE_VECTOR3I: return {&"type": &"Vector3i", &"x": value.x, &"y": value.y, &"z": value.z}
|
||||
TYPE_OBJECT:
|
||||
if value and value is Resource and value.resource_path:
|
||||
return {&"type": &"Resource", &"path": value.resource_path}
|
||||
return null
|
||||
_: return value
|
||||
|
||||
# =============================================================================
|
||||
# setup_autoload
|
||||
# =============================================================================
|
||||
func setup_autoload(args: Dictionary) -> Dictionary:
|
||||
var operation: String = str(args.get(&"operation", ""))
|
||||
|
||||
if operation.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'operation'. Use: add, remove, list"}
|
||||
|
||||
match operation:
|
||||
"list":
|
||||
return _autoload_list()
|
||||
"add":
|
||||
return _autoload_add(args)
|
||||
"remove":
|
||||
return _autoload_remove(args)
|
||||
_:
|
||||
return {&"ok": false, &"error": "Unknown operation: %s. Use: add, remove, list" % operation}
|
||||
|
||||
func _autoload_list() -> Dictionary:
|
||||
var autoloads: Array = []
|
||||
for prop: Dictionary in ProjectSettings.get_property_list():
|
||||
var prop_name: String = prop[&"name"]
|
||||
if not prop_name.begins_with("autoload/"):
|
||||
continue
|
||||
var al_name: String = prop_name.substr(9)
|
||||
var al_path: String = str(ProjectSettings.get_setting(prop_name, ""))
|
||||
var enabled: bool = al_path.begins_with("*")
|
||||
if enabled:
|
||||
al_path = al_path.substr(1)
|
||||
autoloads.append({&"name": al_name, &"path": al_path, &"enabled": enabled})
|
||||
return {&"ok": true, &"autoloads": autoloads, &"count": autoloads.size()}
|
||||
|
||||
func _autoload_add(args: Dictionary) -> Dictionary:
|
||||
var autoload_name: String = str(args.get(&"name", ""))
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
|
||||
if autoload_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'name'"}
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path' for add operation"}
|
||||
|
||||
if not path.begins_with("res://"):
|
||||
path = "res://" + path
|
||||
if not FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File not found: " + path}
|
||||
|
||||
var setting_key := "autoload/" + autoload_name
|
||||
ProjectSettings.set_setting(setting_key, "*" + path)
|
||||
_save_and_refresh_settings()
|
||||
|
||||
return {&"ok": true, &"message": "Registered autoload: %s -> %s" % [autoload_name, path]}
|
||||
|
||||
func _autoload_remove(args: Dictionary) -> Dictionary:
|
||||
var autoload_name: String = str(args.get(&"name", ""))
|
||||
|
||||
if autoload_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'name'"}
|
||||
|
||||
var setting_key := "autoload/" + autoload_name
|
||||
if not ProjectSettings.has_setting(setting_key):
|
||||
return {&"ok": false, &"error": "Autoload not found: " + autoload_name}
|
||||
|
||||
ProjectSettings.clear(setting_key)
|
||||
_save_and_refresh_settings()
|
||||
|
||||
return {&"ok": true, &"message": "Unregistered autoload: " + autoload_name}
|
||||
|
||||
# =============================================================================
|
||||
# Editor Output Panel access
|
||||
# =============================================================================
|
||||
# We read directly from the editor's internal EditorLog RichTextLabel.
|
||||
# This is real-time and matches exactly what the user sees in the Output panel.
|
||||
# =============================================================================
|
||||
|
||||
func _get_editor_log_rtl() -> RichTextLabel:
|
||||
"""Find (and cache) the RichTextLabel inside the editor's Output panel."""
|
||||
if is_instance_valid(_editor_log_rtl):
|
||||
return _editor_log_rtl
|
||||
if not _editor_plugin:
|
||||
return null
|
||||
var base := _editor_plugin.get_editor_interface().get_base_control()
|
||||
var editor_log := _find_node_by_class(base, "EditorLog")
|
||||
if editor_log:
|
||||
_editor_log_rtl = _find_child_rtl(editor_log)
|
||||
return _editor_log_rtl
|
||||
|
||||
func _find_node_by_class(root: Node, cls_name: String) -> Node:
|
||||
if root.get_class() == cls_name:
|
||||
return root
|
||||
for child: Node in root.get_children():
|
||||
var found := _find_node_by_class(child, cls_name)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
func _find_child_rtl(node: Node) -> RichTextLabel:
|
||||
for child: Node in node.get_children():
|
||||
if child is RichTextLabel:
|
||||
return child
|
||||
var found := _find_child_rtl(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
func _read_output_panel_lines() -> Array:
|
||||
"""Return all non-empty lines from the editor Output panel (after clear offset)."""
|
||||
var rtl := _get_editor_log_rtl()
|
||||
if not rtl:
|
||||
return []
|
||||
var full_text: String = rtl.get_parsed_text()
|
||||
if _clear_char_offset > 0 and _clear_char_offset < full_text.length():
|
||||
full_text = full_text.substr(_clear_char_offset)
|
||||
elif _clear_char_offset >= full_text.length():
|
||||
return []
|
||||
var lines: Array = []
|
||||
for line: String in full_text.split("\n"):
|
||||
if not line.strip_edges().is_empty():
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
# =============================================================================
|
||||
# get_console_log
|
||||
# =============================================================================
|
||||
func get_console_log(args: Dictionary) -> Dictionary:
|
||||
var max_lines: int = int(args.get(&"max_lines", 50))
|
||||
|
||||
var rtl := _get_editor_log_rtl()
|
||||
if not rtl:
|
||||
return {&"ok": false,
|
||||
&"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."}
|
||||
|
||||
var all_lines := _read_output_panel_lines()
|
||||
var start := maxi(0, all_lines.size() - max_lines)
|
||||
var lines := all_lines.slice(start)
|
||||
return {&"ok": true, &"lines": lines, &"line_count": lines.size(),
|
||||
&"content": "\n".join(lines)}
|
||||
|
||||
# =============================================================================
|
||||
# get_errors
|
||||
# =============================================================================
|
||||
const _ERROR_PREFIXES: PackedStringArray = [
|
||||
"ERROR:", "SCRIPT ERROR:", "USER ERROR:",
|
||||
"WARNING:", "USER WARNING:", "SCRIPT WARNING:",
|
||||
"Parse Error:", "Invalid",
|
||||
]
|
||||
|
||||
func get_errors(args: Dictionary) -> Dictionary:
|
||||
var max_errors: int = int(args.get(&"max_errors", 50))
|
||||
var include_warnings: bool = bool(args.get(&"include_warnings", true))
|
||||
|
||||
var rtl := _get_editor_log_rtl()
|
||||
if not rtl:
|
||||
return {&"ok": false,
|
||||
&"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."}
|
||||
|
||||
var all_lines := _read_output_panel_lines()
|
||||
|
||||
var all_errors: Array = []
|
||||
for i: int in range(all_lines.size()):
|
||||
var line: String = all_lines[i].strip_edges()
|
||||
if line.is_empty():
|
||||
continue
|
||||
|
||||
var is_error := false
|
||||
var severity := "error"
|
||||
for prefix: String in _ERROR_PREFIXES:
|
||||
if line.begins_with(prefix):
|
||||
is_error = true
|
||||
if "WARNING" in prefix:
|
||||
severity = "warning"
|
||||
break
|
||||
|
||||
# Godot continuation lines: "at: res://path/file.gd:123"
|
||||
if not is_error and line.begins_with("at: ") and "res://" in line:
|
||||
if all_errors.size() > 0:
|
||||
var prev: Dictionary = all_errors[all_errors.size() - 1]
|
||||
var loc := _extract_file_line(line)
|
||||
if not loc.is_empty():
|
||||
prev[&"file"] = loc.get(&"file", "")
|
||||
prev[&"line"] = loc.get(&"line", 0)
|
||||
continue
|
||||
|
||||
if not is_error:
|
||||
continue
|
||||
if severity == "warning" and not include_warnings:
|
||||
continue
|
||||
|
||||
var error_info := {&"message": line, &"severity": severity}
|
||||
var loc := _extract_file_line(line)
|
||||
if not loc.is_empty():
|
||||
error_info[&"file"] = loc.get(&"file", "")
|
||||
error_info[&"line"] = loc.get(&"line", 0)
|
||||
all_errors.append(error_info)
|
||||
|
||||
# Return the most recent errors
|
||||
var start := maxi(0, all_errors.size() - max_errors)
|
||||
var errors := all_errors.slice(start)
|
||||
return {&"ok": true, &"errors": errors, &"error_count": errors.size(),
|
||||
&"summary": "%d error(s) found" % errors.size()}
|
||||
|
||||
func _extract_file_line(text: String) -> Dictionary:
|
||||
var idx := text.find("res://")
|
||||
if idx == -1:
|
||||
return {}
|
||||
var rest := text.substr(idx)
|
||||
var colon_idx := rest.find(":", 6)
|
||||
if colon_idx == -1:
|
||||
return {&"file": rest.strip_edges()}
|
||||
var file_path := rest.substr(0, colon_idx)
|
||||
var after_colon := rest.substr(colon_idx + 1)
|
||||
var line_str := ""
|
||||
for c in after_colon:
|
||||
if c.is_valid_int():
|
||||
line_str += c
|
||||
else:
|
||||
break
|
||||
if not line_str.is_empty():
|
||||
return {&"file": file_path, &"line": int(line_str)}
|
||||
return {&"file": file_path}
|
||||
|
||||
# =============================================================================
|
||||
# clear_console_log
|
||||
# =============================================================================
|
||||
func clear_console_log(_args: Dictionary) -> Dictionary:
|
||||
var rtl := _get_editor_log_rtl()
|
||||
if not rtl:
|
||||
return {&"ok": false,
|
||||
&"error": "Could not access the Godot editor Output panel. Make sure the MCP plugin is enabled and running inside the Godot editor."}
|
||||
|
||||
# Actually clear the editor Output panel
|
||||
rtl.clear()
|
||||
_clear_char_offset = 0
|
||||
return {&"ok": true,
|
||||
&"message": "Console log cleared."}
|
||||
|
||||
# =============================================================================
|
||||
# open_in_godot
|
||||
# =============================================================================
|
||||
func open_in_godot(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
var line: int = int(args.get(&"line", 0))
|
||||
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path'"}
|
||||
|
||||
if not path.begins_with("res://"):
|
||||
path = "res://" + path
|
||||
|
||||
if not _editor_plugin:
|
||||
return {&"ok": false, &"error": "Editor plugin not available"}
|
||||
|
||||
var ei = _editor_plugin.get_editor_interface()
|
||||
|
||||
if path.ends_with(".gd") or path.ends_with(".shader"):
|
||||
var script = load(path)
|
||||
if script:
|
||||
ei.edit_resource(script)
|
||||
if line > 0:
|
||||
ei.get_script_editor().goto_line(line - 1)
|
||||
else:
|
||||
return {&"ok": false, &"error": "Could not load: " + path}
|
||||
elif path.ends_with(".tscn") or path.ends_with(".scn"):
|
||||
ei.open_scene_from_path(path)
|
||||
else:
|
||||
var res = load(path)
|
||||
if res:
|
||||
ei.edit_resource(res)
|
||||
|
||||
return {&"ok": true, &"message": "Opened %s%s" % [path, " at line %d" % line if line > 0 else ""]}
|
||||
|
||||
# =============================================================================
|
||||
# scene_tree_dump
|
||||
# =============================================================================
|
||||
func scene_tree_dump(_args: Dictionary) -> Dictionary:
|
||||
if not _editor_plugin:
|
||||
return {&"ok": false, &"error": "Editor plugin not available"}
|
||||
|
||||
var ei = _editor_plugin.get_editor_interface()
|
||||
var edited_scene = ei.get_edited_scene_root()
|
||||
|
||||
if not edited_scene:
|
||||
return {&"ok": true, &"tree": "(no scene open)", &"message": "No scene is currently open in the editor"}
|
||||
|
||||
var tree_text := _dump_node(edited_scene, 0)
|
||||
|
||||
return {&"ok": true, &"tree": tree_text, &"scene_path": edited_scene.scene_file_path}
|
||||
|
||||
func _dump_node(node: Node, depth: int) -> String:
|
||||
var indent := " ".repeat(depth)
|
||||
var line := "%s%s (%s)" % [indent, node.name, node.get_class()]
|
||||
|
||||
var script = node.get_script()
|
||||
if script:
|
||||
line += " [%s]" % script.resource_path.get_file()
|
||||
|
||||
var children := node.get_children()
|
||||
if children.is_empty():
|
||||
return line
|
||||
|
||||
var parts: PackedStringArray = [line]
|
||||
for child: Node in children:
|
||||
parts.append(_dump_node(child, depth + 1))
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dig5fifwwanvf
|
||||
@@ -0,0 +1,979 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name SceneTools
|
||||
## Scene operation tools for MCP.
|
||||
## Handles: create_scene, read_scene, add_node, remove_node, modify_node_property,
|
||||
## rename_node, move_node, attach_script, detach_script, set_collision_shape,
|
||||
## set_sprite_texture
|
||||
|
||||
const _SKIP_PROPS: Dictionary[String, bool] = {
|
||||
"script": true, "owner": true, "scene_file_path": true,
|
||||
"unique_name_in_owner": true, "editor_description": true,
|
||||
}
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
# =============================================================================
|
||||
# Shared helpers
|
||||
# =============================================================================
|
||||
func _refresh_and_reload(scene_path: String) -> void:
|
||||
_refresh_filesystem()
|
||||
_reload_scene_in_editor(scene_path)
|
||||
|
||||
func _refresh_filesystem() -> void:
|
||||
if _editor_plugin:
|
||||
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
|
||||
|
||||
func _reload_scene_in_editor(scene_path: String) -> void:
|
||||
if not _editor_plugin:
|
||||
return
|
||||
var ei = _editor_plugin.get_editor_interface()
|
||||
var edited = ei.get_edited_scene_root()
|
||||
if edited and edited.scene_file_path == scene_path:
|
||||
ei.reload_scene_from_path(scene_path)
|
||||
|
||||
func _ensure_res_path(path: String) -> String:
|
||||
if not path.begins_with("res://"):
|
||||
return "res://" + path
|
||||
return path
|
||||
|
||||
func _load_scene(scene_path: String) -> Array:
|
||||
"""Returns [scene_root, error_dict]. If error_dict is not empty, scene_root is null."""
|
||||
if not FileAccess.file_exists(scene_path):
|
||||
return [null, {&"ok": false, &"error": "Scene does not exist: " + scene_path}]
|
||||
|
||||
var packed = load(scene_path) as PackedScene
|
||||
if not packed:
|
||||
return [null, {&"ok": false, &"error": "Failed to load scene: " + scene_path}]
|
||||
|
||||
var root = packed.instantiate()
|
||||
if not root:
|
||||
return [null, {&"ok": false, &"error": "Failed to instantiate scene"}]
|
||||
|
||||
return [root, {}]
|
||||
|
||||
func _save_scene(scene_root: Node, scene_path: String) -> Dictionary:
|
||||
"""Pack and save a scene. Returns error dict or empty on success."""
|
||||
var packed = PackedScene.new()
|
||||
var pack_result = packed.pack(scene_root)
|
||||
if pack_result != OK:
|
||||
scene_root.queue_free()
|
||||
return {&"ok": false, &"error": "Failed to pack scene: " + str(pack_result)}
|
||||
|
||||
var save_result = ResourceSaver.save(packed, scene_path)
|
||||
scene_root.queue_free()
|
||||
|
||||
if save_result != OK:
|
||||
return {&"ok": false, &"error": "Failed to save scene: " + str(save_result)}
|
||||
|
||||
_refresh_and_reload(scene_path)
|
||||
return {}
|
||||
|
||||
func _find_node(scene_root: Node, node_path: String) -> Node:
|
||||
if node_path == "." or node_path.is_empty():
|
||||
return scene_root
|
||||
return scene_root.get_node_or_null(node_path)
|
||||
|
||||
func _parse_value(value: Variant) -> Variant:
|
||||
"""Convert dictionary-encoded types to Godot types."""
|
||||
if value is Dictionary:
|
||||
var t: String = value.get(&"type", "")
|
||||
match t:
|
||||
"Vector2": return Vector2(value.get(&"x", 0), value.get(&"y", 0))
|
||||
"Vector3": return Vector3(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
|
||||
"Color": return Color(value.get(&"r", 1), value.get(&"g", 1), value.get(&"b", 1), value.get(&"a", 1))
|
||||
"Vector2i": return Vector2i(value.get(&"x", 0), value.get(&"y", 0))
|
||||
"Vector3i": return Vector3i(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
|
||||
"Rect2": return Rect2(value.get(&"x", 0), value.get(&"y", 0), value.get(&"width", 0), value.get(&"height", 0))
|
||||
return value
|
||||
|
||||
func _set_node_properties(node: Node, properties: Dictionary) -> void:
|
||||
for prop_name: String in properties:
|
||||
var prop_value = _parse_value(properties[prop_name])
|
||||
node.set(prop_name, prop_value)
|
||||
|
||||
func _serialize_value(value: Variant) -> Variant:
|
||||
match typeof(value):
|
||||
TYPE_VECTOR2: return {&"type": &"Vector2", &"x": value.x, &"y": value.y}
|
||||
TYPE_VECTOR3: return {&"type": &"Vector3", &"x": value.x, &"y": value.y, &"z": value.z}
|
||||
TYPE_COLOR: return {&"type": &"Color", &"r": value.r, &"g": value.g, &"b": value.b, &"a": value.a}
|
||||
TYPE_VECTOR2I: return {&"type": &"Vector2i", &"x": value.x, &"y": value.y}
|
||||
TYPE_VECTOR3I: return {&"type": &"Vector3i", &"x": value.x, &"y": value.y, &"z": value.z}
|
||||
TYPE_RECT2: return {&"type": &"Rect2", &"x": value.position.x, &"y": value.position.y, &"width": value.size.x, &"height": value.size.y}
|
||||
TYPE_OBJECT:
|
||||
if value and value is Resource and value.resource_path:
|
||||
return {&"type": &"Resource", &"path": value.resource_path}
|
||||
return null
|
||||
_: return value
|
||||
|
||||
# =============================================================================
|
||||
# create_scene
|
||||
# =============================================================================
|
||||
func create_scene(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var root_node_name: String = str(args.get(&"root_node_name", "Node"))
|
||||
var root_node_type: String = str(args.get(&"root_node_type", ""))
|
||||
var nodes: Array = args.get(&"nodes", [])
|
||||
var attach_script_path: String = str(args.get(&"attach_script", ""))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path' parameter"}
|
||||
if root_node_type.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'root_node_type' parameter"}
|
||||
if not scene_path.ends_with(".tscn"):
|
||||
scene_path += ".tscn"
|
||||
if FileAccess.file_exists(scene_path):
|
||||
return {&"ok": false, &"error": "Scene already exists: " + scene_path}
|
||||
if not ClassDB.class_exists(root_node_type):
|
||||
return {&"ok": false, &"error": "Invalid root node type: " + root_node_type}
|
||||
|
||||
# Ensure parent directory
|
||||
var dir_path := scene_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
|
||||
var root: Node = ClassDB.instantiate(root_node_type) as Node
|
||||
if not root:
|
||||
return {&"ok": false, &"error": "Failed to create root node of type: " + root_node_type}
|
||||
root.name = root_node_name
|
||||
|
||||
if not attach_script_path.is_empty():
|
||||
var script_res = load(attach_script_path)
|
||||
if script_res:
|
||||
root.set_script(script_res)
|
||||
|
||||
var node_count := 0
|
||||
for node_data: Variant in nodes:
|
||||
if typeof(node_data) == TYPE_DICTIONARY:
|
||||
var created = _create_node_recursive(node_data, root, root)
|
||||
if created:
|
||||
node_count += _count_nodes(created)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"path": scene_path, &"root_type": root_node_type, &"child_count": node_count,
|
||||
&"message": "Scene created at " + scene_path}
|
||||
|
||||
func _create_node_recursive(data: Dictionary, parent: Node, owner: Node) -> Node:
|
||||
var n_name: String = str(data.get(&"name", "Node"))
|
||||
var n_type: String = str(data.get(&"type", "Node"))
|
||||
var n_script: String = str(data.get(&"script", ""))
|
||||
var props: Dictionary = data.get(&"properties", {})
|
||||
var children: Array = data.get(&"children", [])
|
||||
|
||||
if not ClassDB.class_exists(n_type):
|
||||
return null
|
||||
var node: Node = ClassDB.instantiate(n_type) as Node
|
||||
if not node:
|
||||
return null
|
||||
|
||||
node.name = n_name
|
||||
_set_node_properties(node, props)
|
||||
|
||||
if not n_script.is_empty():
|
||||
var s = load(n_script)
|
||||
if s:
|
||||
node.set_script(s)
|
||||
|
||||
parent.add_child(node)
|
||||
node.owner = owner
|
||||
|
||||
for child_data: Variant in children:
|
||||
if typeof(child_data) == TYPE_DICTIONARY:
|
||||
_create_node_recursive(child_data, node, owner)
|
||||
return node
|
||||
|
||||
func _count_nodes(node: Node) -> int:
|
||||
var count := 1
|
||||
for child: Node in node.get_children():
|
||||
count += _count_nodes(child)
|
||||
return count
|
||||
|
||||
# =============================================================================
|
||||
# read_scene
|
||||
# =============================================================================
|
||||
func read_scene(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var include_properties: bool = args.get(&"include_properties", false)
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path' parameter"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var structure = _build_node_structure(root, include_properties)
|
||||
root.queue_free()
|
||||
|
||||
return {&"ok": true, &"scene_path": scene_path, &"root": structure}
|
||||
|
||||
func _build_node_structure(node: Node, include_props: bool, path: String = ".") -> Dictionary:
|
||||
const PROPERTIES: PackedStringArray = ["position", "rotation", "scale", "size", "offset", "visible",
|
||||
"modulate", "z_index", "text", "collision_layer", "collision_mask", "mass"]
|
||||
var data := {&"name": str(node.name), &"type": node.get_class(), &"path": path, &"children": []}
|
||||
var script = node.get_script()
|
||||
if script:
|
||||
data[&"script"] = script.resource_path
|
||||
|
||||
if include_props:
|
||||
var props := {}
|
||||
for prop_name: String in PROPERTIES:
|
||||
var val = node.get(prop_name)
|
||||
if val != null:
|
||||
props[prop_name] = _serialize_value(val)
|
||||
if not props.is_empty():
|
||||
data[&"properties"] = props
|
||||
|
||||
for child: Node in node.get_children():
|
||||
var child_path = child.name if path == "." else path + "/" + child.name
|
||||
data[&"children"].append(_build_node_structure(child, include_props, child_path))
|
||||
return data
|
||||
|
||||
# =============================================================================
|
||||
# add_node
|
||||
# =============================================================================
|
||||
func add_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_name: String = str(args.get(&"node_name", ""))
|
||||
var node_type: String = str(args.get(&"node_type", "Node"))
|
||||
var parent_path: String = str(args.get(&"parent_path", "."))
|
||||
var properties: Dictionary = args.get(&"properties", {})
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'node_name'"}
|
||||
if not ClassDB.class_exists(node_type):
|
||||
return {&"ok": false, &"error": "Invalid node type: " + node_type}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var parent = _find_node(root, parent_path)
|
||||
if not parent:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Parent node not found: " + parent_path}
|
||||
|
||||
var new_node: Node = ClassDB.instantiate(node_type) as Node
|
||||
if not new_node:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Failed to create node of type: " + node_type}
|
||||
|
||||
new_node.name = node_name
|
||||
_set_node_properties(new_node, properties)
|
||||
parent.add_child(new_node)
|
||||
new_node.owner = root
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"scene_path": scene_path, &"node_name": node_name, &"node_type": node_type,
|
||||
&"message": "Added %s (%s) to scene" % [node_name, node_type]}
|
||||
|
||||
# =============================================================================
|
||||
# remove_node
|
||||
# =============================================================================
|
||||
func remove_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", ""))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_path.strip_edges().is_empty() or node_path == ".":
|
||||
return {&"ok": false, &"error": "Cannot remove root node"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = root.get_node_or_null(node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var n_name = target.name
|
||||
var n_type = target.get_class()
|
||||
target.get_parent().remove_child(target)
|
||||
target.queue_free()
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"scene_path": scene_path, &"removed_node": node_path,
|
||||
&"message": "Removed %s (%s)" % [n_name, n_type]}
|
||||
|
||||
# =============================================================================
|
||||
# modify_node_property
|
||||
# =============================================================================
|
||||
func modify_node_property(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
var property_name: String = str(args.get(&"property_name", ""))
|
||||
var value = args.get(&"value")
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if property_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'property_name'"}
|
||||
if value == null:
|
||||
return {&"ok": false, &"error": "Missing 'value'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
# Check property exists
|
||||
var prop_exists := false
|
||||
for prop: Dictionary in target.get_property_list():
|
||||
if prop[&"name"] == property_name:
|
||||
prop_exists = true
|
||||
break
|
||||
if not prop_exists:
|
||||
var node_type = target.get_class()
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Property '%s' not found on %s (%s). Use get_node_properties to discover available properties." % [property_name, node_path, node_type]}
|
||||
|
||||
var parsed = _parse_value(value)
|
||||
var old_value = target.get(property_name)
|
||||
|
||||
# Validate resource type compatibility
|
||||
if old_value is Resource and not (parsed is Resource):
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Property '%s' expects a Resource. Use specialized tools (set_collision_shape, set_sprite_texture) instead." % property_name}
|
||||
|
||||
target.set(property_name, parsed)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"scene_path": scene_path, &"node_path": node_path,
|
||||
&"property_name": property_name, &"old_value": str(old_value), &"new_value": str(parsed),
|
||||
&"message": "Set %s.%s = %s" % [node_path, property_name, str(parsed)]}
|
||||
|
||||
# =============================================================================
|
||||
# rename_node
|
||||
# =============================================================================
|
||||
func rename_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", ""))
|
||||
var new_name: String = str(args.get(&"new_name", ""))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'node_path'"}
|
||||
if new_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'new_name'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var old_name = target.name
|
||||
target.name = new_name
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"old_name": str(old_name), &"new_name": new_name,
|
||||
&"message": "Renamed '%s' to '%s'" % [old_name, new_name]}
|
||||
|
||||
# =============================================================================
|
||||
# move_node
|
||||
# =============================================================================
|
||||
func move_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", ""))
|
||||
var new_parent_path: String = str(args.get(&"new_parent_path", "."))
|
||||
var sibling_index: int = int(args.get(&"sibling_index", -1))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_path.strip_edges().is_empty() or node_path == ".":
|
||||
return {&"ok": false, &"error": "Cannot move root node"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = root.get_node_or_null(node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var new_parent = _find_node(root, new_parent_path)
|
||||
if not new_parent:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "New parent not found: " + new_parent_path}
|
||||
|
||||
target.get_parent().remove_child(target)
|
||||
new_parent.add_child(target)
|
||||
target.owner = root
|
||||
|
||||
if sibling_index >= 0:
|
||||
new_parent.move_child(target, mini(sibling_index, new_parent.get_child_count() - 1))
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"message": "Moved '%s' to '%s'" % [node_path, new_parent_path]}
|
||||
|
||||
# =============================================================================
|
||||
# duplicate_node
|
||||
# =============================================================================
|
||||
func duplicate_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", ""))
|
||||
var new_name: String = str(args.get(&"new_name", ""))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_path.strip_edges().is_empty() or node_path == ".":
|
||||
return {&"ok": false, &"error": "Cannot duplicate root node"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = root.get_node_or_null(node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var parent = target.get_parent()
|
||||
if not parent:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Cannot duplicate - no parent"}
|
||||
|
||||
var duplicate = target.duplicate()
|
||||
|
||||
if new_name.is_empty():
|
||||
var base_name = target.name
|
||||
var counter = 2
|
||||
new_name = base_name + str(counter)
|
||||
while parent.has_node(NodePath(new_name)):
|
||||
counter += 1
|
||||
new_name = base_name + str(counter)
|
||||
|
||||
duplicate.name = new_name
|
||||
parent.add_child(duplicate)
|
||||
|
||||
_set_owner_recursive(duplicate, root)
|
||||
|
||||
var original_index = target.get_index()
|
||||
parent.move_child(duplicate, original_index + 1)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"new_name": new_name,
|
||||
&"message": "Duplicated '%s' as '%s'" % [node_path, new_name]}
|
||||
|
||||
|
||||
func _set_owner_recursive(node: Node, owner: Node) -> void:
|
||||
node.owner = owner
|
||||
for child: Node in node.get_children():
|
||||
_set_owner_recursive(child, owner)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# reorder_node - simpler function just for changing sibling order
|
||||
# =============================================================================
|
||||
func reorder_node(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", ""))
|
||||
var new_index: int = int(args.get(&"new_index", -1))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if node_path.strip_edges().is_empty() or node_path == ".":
|
||||
return {&"ok": false, &"error": "Cannot reorder root node"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = root.get_node_or_null(node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var parent = target.get_parent()
|
||||
if not parent:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Cannot reorder - no parent"}
|
||||
|
||||
var old_index = target.get_index()
|
||||
var max_index = parent.get_child_count() - 1
|
||||
new_index = clampi(new_index, 0, max_index)
|
||||
|
||||
if old_index == new_index:
|
||||
root.queue_free()
|
||||
return {&"ok": true, &"message": "No change needed"}
|
||||
|
||||
parent.move_child(target, new_index)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"old_index": old_index, &"new_index": new_index,
|
||||
&"message": "Moved '%s' from index %d to %d" % [node_path, old_index, new_index]}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# attach_script
|
||||
# =============================================================================
|
||||
func attach_script(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
var script_path: String = str(args.get(&"script_path", ""))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if script_path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'script_path'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var script_res = load(script_path)
|
||||
if not script_res:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Failed to load script: " + script_path}
|
||||
|
||||
target.set_script(script_res)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"message": "Attached %s to node '%s'" % [script_path, node_path]}
|
||||
|
||||
# =============================================================================
|
||||
# detach_script
|
||||
# =============================================================================
|
||||
func detach_script(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
target.set_script(null)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"message": "Detached script from node '%s'" % node_path}
|
||||
|
||||
# =============================================================================
|
||||
# set_collision_shape
|
||||
# =============================================================================
|
||||
func set_collision_shape(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
var shape_type: String = str(args.get(&"shape_type", ""))
|
||||
var shape_params: Dictionary = args.get(&"shape_params", {})
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if shape_type.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'shape_type'"}
|
||||
if not ClassDB.class_exists(shape_type):
|
||||
return {&"ok": false, &"error": "Invalid shape type: " + shape_type}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var shape = ClassDB.instantiate(shape_type)
|
||||
if not shape:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Failed to create shape: " + shape_type}
|
||||
|
||||
if shape_params.has(&"radius"):
|
||||
shape.set("radius", float(shape_params[&"radius"]))
|
||||
if shape_params.has(&"height"):
|
||||
shape.set("height", float(shape_params[&"height"]))
|
||||
if shape_params.has(&"size"):
|
||||
var size_data = shape_params[&"size"]
|
||||
if typeof(size_data) == TYPE_DICTIONARY:
|
||||
if size_data.has(&"z"):
|
||||
shape.set("size", Vector3(size_data.get(&"x", 1), size_data.get(&"y", 1), size_data.get(&"z", 1)))
|
||||
else:
|
||||
shape.set("size", Vector2(size_data.get(&"x", 1), size_data.get(&"y", 1)))
|
||||
|
||||
target.set("shape", shape)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"message": "Set %s on node '%s'" % [shape_type, node_path]}
|
||||
|
||||
# =============================================================================
|
||||
# set_sprite_texture
|
||||
# =============================================================================
|
||||
func set_sprite_texture(args: Dictionary) -> Dictionary:
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
var texture_type: String = str(args.get(&"texture_type", ""))
|
||||
var texture_params: Dictionary = args.get(&"texture_params", {})
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if texture_type.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'texture_type'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var texture: Texture2D = null
|
||||
|
||||
match texture_type:
|
||||
"ImageTexture":
|
||||
var tex_path: String = str(texture_params.get(&"path", ""))
|
||||
if tex_path.is_empty():
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Missing 'path' in texture_params for ImageTexture"}
|
||||
texture = load(tex_path)
|
||||
if not texture:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Failed to load texture: " + tex_path}
|
||||
|
||||
"PlaceholderTexture2D":
|
||||
texture = PlaceholderTexture2D.new()
|
||||
var size_data = texture_params.get(&"size", {&"x": 64, &"y": 64})
|
||||
if typeof(size_data) == TYPE_DICTIONARY:
|
||||
texture.size = Vector2(size_data.get(&"x", 64), size_data.get(&"y", 64))
|
||||
|
||||
"GradientTexture2D":
|
||||
texture = GradientTexture2D.new()
|
||||
texture.width = int(texture_params.get(&"width", 64))
|
||||
texture.height = int(texture_params.get(&"height", 64))
|
||||
|
||||
"NoiseTexture2D":
|
||||
texture = NoiseTexture2D.new()
|
||||
texture.width = int(texture_params.get(&"width", 64))
|
||||
texture.height = int(texture_params.get(&"height", 64))
|
||||
|
||||
_:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Unknown texture type: " + texture_type}
|
||||
|
||||
target.set("texture", texture)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {&"ok": true, &"message": "Set %s texture on node '%s'" % [texture_type, node_path]}
|
||||
|
||||
# =============================================================================
|
||||
# get_scene_hierarchy (for visualizer)
|
||||
# =============================================================================
|
||||
func get_scene_hierarchy(args: Dictionary) -> Dictionary:
|
||||
"""Get the full scene hierarchy with node information for the visualizer."""
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var hierarchy = _build_hierarchy_recursive(root, ".")
|
||||
root.queue_free()
|
||||
|
||||
return {&"ok": true, &"scene_path": scene_path, &"hierarchy": hierarchy}
|
||||
|
||||
func _build_hierarchy_recursive(node: Node, path: String) -> Dictionary:
|
||||
"""Build node hierarchy with all info needed for visualizer."""
|
||||
var data := {
|
||||
&"name": str(node.name),
|
||||
&"type": node.get_class(),
|
||||
&"path": path,
|
||||
&"children": [],
|
||||
&"child_count": node.get_child_count()
|
||||
}
|
||||
|
||||
var script = node.get_script()
|
||||
if script:
|
||||
data[&"script"] = script.resource_path
|
||||
|
||||
var parent = node.get_parent()
|
||||
if parent:
|
||||
data[&"index"] = node.get_index()
|
||||
|
||||
for i: int in range(node.get_child_count()):
|
||||
var child = node.get_child(i)
|
||||
var child_path = child.name if path == "." else path + "/" + child.name
|
||||
data[&"children"].append(_build_hierarchy_recursive(child, child_path))
|
||||
|
||||
return data
|
||||
|
||||
# =============================================================================
|
||||
# get_scene_node_properties (dynamic property fetching)
|
||||
# =============================================================================
|
||||
func get_scene_node_properties(args: Dictionary) -> Dictionary:
|
||||
"""Get all properties of a specific node in a scene with their current values."""
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var node_type = target.get_class()
|
||||
var properties: Array = []
|
||||
var categories: Dictionary = {}
|
||||
|
||||
for prop: Dictionary in target.get_property_list():
|
||||
var prop_name: String = prop[&"name"]
|
||||
|
||||
if prop_name.begins_with("_"):
|
||||
continue
|
||||
if _SKIP_PROPS.has(prop_name):
|
||||
continue
|
||||
|
||||
var usage = prop.get(&"usage", 0)
|
||||
if not (usage & PROPERTY_USAGE_EDITOR):
|
||||
continue
|
||||
|
||||
var current_value = target.get(prop_name)
|
||||
|
||||
var prop_info := {
|
||||
&"name": prop_name,
|
||||
&"type": prop[&"type"],
|
||||
&"type_name": _type_id_to_name(prop[&"type"]),
|
||||
&"hint": prop.get(&"hint", 0),
|
||||
&"hint_string": prop.get(&"hint_string", ""),
|
||||
&"value": _serialize_value(current_value),
|
||||
&"usage": usage
|
||||
}
|
||||
|
||||
var category = _get_property_category(target, prop_name)
|
||||
prop_info[&"category"] = category
|
||||
|
||||
if not categories.has(category):
|
||||
categories[category] = []
|
||||
categories[category].append(prop_info)
|
||||
properties.append(prop_info)
|
||||
|
||||
var chain: Array = []
|
||||
var cls: String = node_type
|
||||
while cls != "":
|
||||
chain.append(cls)
|
||||
cls = ClassDB.get_parent_class(cls)
|
||||
|
||||
root.queue_free()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"scene_path": scene_path,
|
||||
&"node_path": node_path,
|
||||
&"node_type": node_type,
|
||||
&"node_name": target.name,
|
||||
&"inheritance_chain": chain,
|
||||
&"properties": properties,
|
||||
&"categories": categories,
|
||||
&"property_count": properties.size()
|
||||
}
|
||||
|
||||
func _type_id_to_name(type_id: int) -> String:
|
||||
"""Convert Godot type ID to human-readable name."""
|
||||
match type_id:
|
||||
TYPE_NIL: return "null"
|
||||
TYPE_BOOL: return "bool"
|
||||
TYPE_INT: return "int"
|
||||
TYPE_FLOAT: return "float"
|
||||
TYPE_STRING: return "String"
|
||||
TYPE_VECTOR2: return "Vector2"
|
||||
TYPE_VECTOR2I: return "Vector2i"
|
||||
TYPE_RECT2: return "Rect2"
|
||||
TYPE_RECT2I: return "Rect2i"
|
||||
TYPE_VECTOR3: return "Vector3"
|
||||
TYPE_VECTOR3I: return "Vector3i"
|
||||
TYPE_TRANSFORM2D: return "Transform2D"
|
||||
TYPE_VECTOR4: return "Vector4"
|
||||
TYPE_VECTOR4I: return "Vector4i"
|
||||
TYPE_PLANE: return "Plane"
|
||||
TYPE_QUATERNION: return "Quaternion"
|
||||
TYPE_AABB: return "AABB"
|
||||
TYPE_BASIS: return "Basis"
|
||||
TYPE_TRANSFORM3D: return "Transform3D"
|
||||
TYPE_PROJECTION: return "Projection"
|
||||
TYPE_COLOR: return "Color"
|
||||
TYPE_STRING_NAME: return "StringName"
|
||||
TYPE_NODE_PATH: return "NodePath"
|
||||
TYPE_RID: return "RID"
|
||||
TYPE_OBJECT: return "Object"
|
||||
TYPE_CALLABLE: return "Callable"
|
||||
TYPE_SIGNAL: return "Signal"
|
||||
TYPE_DICTIONARY: return "Dictionary"
|
||||
TYPE_ARRAY: return "Array"
|
||||
TYPE_PACKED_BYTE_ARRAY: return "PackedByteArray"
|
||||
TYPE_PACKED_INT32_ARRAY: return "PackedInt32Array"
|
||||
TYPE_PACKED_INT64_ARRAY: return "PackedInt64Array"
|
||||
TYPE_PACKED_FLOAT32_ARRAY: return "PackedFloat32Array"
|
||||
TYPE_PACKED_FLOAT64_ARRAY: return "PackedFloat64Array"
|
||||
TYPE_PACKED_STRING_ARRAY: return "PackedStringArray"
|
||||
TYPE_PACKED_VECTOR2_ARRAY: return "PackedVector2Array"
|
||||
TYPE_PACKED_VECTOR3_ARRAY: return "PackedVector3Array"
|
||||
TYPE_PACKED_COLOR_ARRAY: return "PackedColorArray"
|
||||
_: return "Variant"
|
||||
|
||||
func _get_property_category(node: Node, prop_name: String) -> String:
|
||||
"""Determine which class in the hierarchy defines this property."""
|
||||
var cls: String = node.get_class()
|
||||
while cls != "":
|
||||
var class_props = ClassDB.class_get_property_list(cls, true)
|
||||
for prop: Dictionary in class_props:
|
||||
if prop[&"name"] == prop_name:
|
||||
return cls
|
||||
cls = ClassDB.get_parent_class(cls)
|
||||
return node.get_class()
|
||||
|
||||
# =============================================================================
|
||||
# set_scene_node_property (for visualizer inline editing)
|
||||
# =============================================================================
|
||||
func set_scene_node_property(args: Dictionary) -> Dictionary:
|
||||
"""Set a property on a node in a scene (supports complex types)."""
|
||||
var scene_path: String = _ensure_res_path(str(args.get(&"scene_path", "")))
|
||||
var node_path: String = str(args.get(&"node_path", "."))
|
||||
var property_name: String = str(args.get(&"property_name", ""))
|
||||
var value = args.get(&"value")
|
||||
var value_type: int = int(args.get(&"value_type", -1))
|
||||
|
||||
if scene_path.strip_edges() == "res://":
|
||||
return {&"ok": false, &"error": "Missing 'scene_path'"}
|
||||
if property_name.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'property_name'"}
|
||||
|
||||
var result := _load_scene(scene_path)
|
||||
if not result[1].is_empty():
|
||||
return result[1]
|
||||
|
||||
var root: Node = result[0]
|
||||
var target = _find_node(root, node_path)
|
||||
if not target:
|
||||
root.queue_free()
|
||||
return {&"ok": false, &"error": "Node not found: " + node_path}
|
||||
|
||||
var parsed_value = _parse_typed_value(value, value_type)
|
||||
var old_value = target.get(property_name)
|
||||
|
||||
target.set(property_name, parsed_value)
|
||||
|
||||
var err := _save_scene(root, scene_path)
|
||||
if not err.is_empty():
|
||||
return err
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"scene_path": scene_path,
|
||||
&"node_path": node_path,
|
||||
&"property_name": property_name,
|
||||
&"old_value": _serialize_value(old_value),
|
||||
&"new_value": _serialize_value(parsed_value),
|
||||
&"message": "Set %s.%s" % [node_path, property_name]
|
||||
}
|
||||
|
||||
func _parse_typed_value(value, type_hint: int):
|
||||
"""Parse a value based on its type hint."""
|
||||
if type_hint == -1:
|
||||
return _parse_value(value)
|
||||
|
||||
if typeof(value) == TYPE_DICTIONARY:
|
||||
if value.has(&"type"):
|
||||
return _parse_value(value)
|
||||
|
||||
match type_hint:
|
||||
TYPE_VECTOR2:
|
||||
return Vector2(value.get(&"x", 0), value.get(&"y", 0))
|
||||
TYPE_VECTOR2I:
|
||||
return Vector2i(value.get(&"x", 0), value.get(&"y", 0))
|
||||
TYPE_VECTOR3:
|
||||
return Vector3(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
|
||||
TYPE_VECTOR3I:
|
||||
return Vector3i(value.get(&"x", 0), value.get(&"y", 0), value.get(&"z", 0))
|
||||
TYPE_COLOR:
|
||||
return Color(value.get(&"r", 1), value.get(&"g", 1), value.get(&"b", 1), value.get(&"a", 1))
|
||||
TYPE_RECT2:
|
||||
return Rect2(value.get(&"x", 0), value.get(&"y", 0), value.get(&"width", 0), value.get(&"height", 0))
|
||||
|
||||
return value
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbcix6lo0tanv
|
||||
@@ -0,0 +1,333 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name ScriptTools
|
||||
## Script and file management tools for MCP.
|
||||
## Handles: edit_script, validate_script, list_scripts,
|
||||
## create_folder, delete_file, rename_file
|
||||
|
||||
var _editor_plugin: EditorPlugin = null
|
||||
|
||||
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
||||
_editor_plugin = plugin
|
||||
|
||||
func _refresh_filesystem() -> void:
|
||||
if _editor_plugin:
|
||||
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
|
||||
|
||||
func _ensure_res_path(path: String) -> String:
|
||||
if not path.begins_with("res://"):
|
||||
return "res://" + path
|
||||
return path
|
||||
|
||||
# =============================================================================
|
||||
# edit_script - Apply a small surgical code edit to a GDScript file
|
||||
# =============================================================================
|
||||
func edit_script(args: Dictionary) -> Dictionary:
|
||||
var edit: Dictionary = args.get(&"edit", {})
|
||||
if edit.is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'edit' payload"}
|
||||
|
||||
var path: String = str(edit.get(&"file", ""))
|
||||
if path.is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'file' in edit"}
|
||||
|
||||
path = _ensure_res_path(path)
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File not found: " + path}
|
||||
|
||||
var spec_type: String = str(edit.get(&"type", "snippet_replace"))
|
||||
if spec_type != "snippet_replace":
|
||||
return {&"ok": false, &"error": "Only 'snippet_replace' type is supported"}
|
||||
|
||||
var old_snippet: String = str(edit.get(&"old_snippet", ""))
|
||||
var new_snippet: String = str(edit.get(&"new_snippet", ""))
|
||||
var context_before: String = str(edit.get(&"context_before", ""))
|
||||
var context_after: String = str(edit.get(&"context_after", ""))
|
||||
|
||||
if old_snippet.is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'old_snippet' in edit"}
|
||||
|
||||
# Read current file content
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if not file:
|
||||
return {&"ok": false, &"error": "Cannot read file: " + path}
|
||||
var content := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
# Find and replace the snippet
|
||||
var search_text := old_snippet
|
||||
var pos := content.find(search_text)
|
||||
|
||||
# If not found directly, try with context
|
||||
if pos == -1 and not context_before.is_empty():
|
||||
var ctx_pos := content.find(context_before)
|
||||
if ctx_pos != -1:
|
||||
var after_ctx := ctx_pos + context_before.length()
|
||||
var remaining := content.substr(after_ctx)
|
||||
var snippet_pos := remaining.find(old_snippet)
|
||||
if snippet_pos != -1:
|
||||
pos = after_ctx + snippet_pos
|
||||
|
||||
if pos == -1:
|
||||
return {&"ok": false, &"error": "Could not find old_snippet in file. Make sure old_snippet matches the file content exactly."}
|
||||
|
||||
# Check for multiple occurrences
|
||||
var second_pos := content.find(search_text, pos + 1)
|
||||
if second_pos != -1 and context_before.is_empty() and context_after.is_empty():
|
||||
return {&"ok": false, &"error": "old_snippet appears multiple times. Add context_before or context_after for disambiguation."}
|
||||
|
||||
# Apply the replacement
|
||||
var original_content := content
|
||||
var new_content := content.substr(0, pos) + new_snippet + content.substr(pos + old_snippet.length())
|
||||
|
||||
# Write back
|
||||
file = FileAccess.open(path, FileAccess.WRITE)
|
||||
if not file:
|
||||
return {&"ok": false, &"error": "Cannot write file: " + path}
|
||||
file.store_string(new_content)
|
||||
file.close()
|
||||
|
||||
# Count changes
|
||||
var old_lines := old_snippet.split("\n")
|
||||
var new_lines := new_snippet.split("\n")
|
||||
var added := maxi(0, new_lines.size() - old_lines.size())
|
||||
var removed := maxi(0, old_lines.size() - new_lines.size())
|
||||
|
||||
_refresh_filesystem()
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"path": path,
|
||||
&"added": added,
|
||||
&"removed": removed,
|
||||
&"auto_applied": true,
|
||||
&"message": "Applied edit to %s (+%d -%d lines)" % [path, added, removed]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# validate_script
|
||||
# =============================================================================
|
||||
func validate_script(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path'"}
|
||||
|
||||
path = _ensure_res_path(path)
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File not found: " + path}
|
||||
|
||||
# Read the source text directly from disk so we validate the *current*
|
||||
# file contents, not a stale resource-cache entry.
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if not file:
|
||||
return {&"ok": false, &"error": "Cannot read file: " + path}
|
||||
var source_code := file.get_as_text()
|
||||
file.close()
|
||||
|
||||
# Create a fresh GDScript instance and assign the source for parsing.
|
||||
var script := GDScript.new()
|
||||
script.source_code = source_code
|
||||
|
||||
# reload() triggers the parser/compiler and returns OK or an error code.
|
||||
var err := script.reload()
|
||||
|
||||
if err != OK:
|
||||
# Try to extract useful details from the Godot output log.
|
||||
var errors := _collect_recent_script_errors(path)
|
||||
return {
|
||||
&"ok": true,
|
||||
&"valid": false,
|
||||
&"path": path,
|
||||
&"error_code": err,
|
||||
&"errors": errors,
|
||||
&"message": "Script has errors." + (" Details: " + "; ".join(errors) if errors.size() > 0 else " Check Godot console for details.")
|
||||
}
|
||||
|
||||
if not script.can_instantiate():
|
||||
return {
|
||||
&"ok": true,
|
||||
&"valid": false,
|
||||
&"path": path,
|
||||
&"message": "Script parsed but cannot be instantiated (may have dependency errors)"
|
||||
}
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"valid": true,
|
||||
&"path": path,
|
||||
&"message": "No syntax errors found"
|
||||
}
|
||||
|
||||
func _collect_recent_script_errors(script_path: String) -> Array:
|
||||
"""Grab recent SCRIPT ERROR / Parse Error lines from the editor Output panel
|
||||
that mention the given script path. Best-effort — returns [] if the panel
|
||||
cannot be accessed."""
|
||||
var errors: Array = []
|
||||
if not _editor_plugin:
|
||||
return errors
|
||||
|
||||
# Find the editor's Output panel RichTextLabel
|
||||
var base := _editor_plugin.get_editor_interface().get_base_control()
|
||||
var editor_log := _find_node_by_class(base, "EditorLog")
|
||||
if not editor_log:
|
||||
return errors
|
||||
var rtl := _find_child_rtl(editor_log)
|
||||
if not rtl:
|
||||
return errors
|
||||
|
||||
var text: String = rtl.get_parsed_text()
|
||||
var short_path := script_path.get_file() # e.g. "player.gd"
|
||||
|
||||
for line: String in text.split("\n"):
|
||||
line = line.strip_edges()
|
||||
if line.is_empty():
|
||||
continue
|
||||
if short_path in line or script_path in line:
|
||||
if line.begins_with("SCRIPT ERROR:") or line.begins_with("Parse Error:") \
|
||||
or line.begins_with("ERROR:") or line.begins_with("at:"):
|
||||
errors.append(line)
|
||||
|
||||
# Keep only the last 10 relevant lines
|
||||
if errors.size() > 10:
|
||||
errors = errors.slice(errors.size() - 10)
|
||||
return errors
|
||||
|
||||
func _find_node_by_class(root: Node, cls_name: String) -> Node:
|
||||
if root.get_class() == cls_name:
|
||||
return root
|
||||
for child: Node in root.get_children():
|
||||
var found := _find_node_by_class(child, cls_name)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
func _find_child_rtl(node: Node) -> RichTextLabel:
|
||||
for child: Node in node.get_children():
|
||||
if child is RichTextLabel:
|
||||
return child
|
||||
var found := _find_child_rtl(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
# =============================================================================
|
||||
# list_scripts
|
||||
# =============================================================================
|
||||
func list_scripts(args: Dictionary) -> Dictionary:
|
||||
var scripts: Array = []
|
||||
_collect_scripts("res://", scripts)
|
||||
|
||||
return {
|
||||
&"ok": true,
|
||||
&"scripts": scripts,
|
||||
&"count": scripts.size()
|
||||
}
|
||||
|
||||
func _collect_scripts(path: String, out: Array) -> void:
|
||||
var dir := DirAccess.open(path)
|
||||
if dir == null:
|
||||
return
|
||||
|
||||
dir.list_dir_begin()
|
||||
var name := dir.get_next()
|
||||
while name != "":
|
||||
if name.begins_with("."):
|
||||
name = dir.get_next()
|
||||
continue
|
||||
|
||||
var full_path := path.path_join(name)
|
||||
if dir.current_is_dir():
|
||||
_collect_scripts(full_path, out)
|
||||
elif name.ends_with(".gd"):
|
||||
out.append(full_path)
|
||||
|
||||
name = dir.get_next()
|
||||
dir.list_dir_end()
|
||||
|
||||
# =============================================================================
|
||||
# create_folder
|
||||
# =============================================================================
|
||||
func create_folder(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path'"}
|
||||
|
||||
path = _ensure_res_path(path)
|
||||
|
||||
if DirAccess.dir_exists_absolute(path):
|
||||
return {&"ok": true, &"path": path, &"message": "Directory already exists"}
|
||||
|
||||
var err := DirAccess.make_dir_recursive_absolute(path)
|
||||
if err != OK:
|
||||
return {&"ok": false, &"error": "Failed to create directory: " + str(err)}
|
||||
|
||||
_refresh_filesystem()
|
||||
|
||||
return {&"ok": true, &"path": path, &"message": "Directory created"}
|
||||
|
||||
# =============================================================================
|
||||
# delete_file
|
||||
# =============================================================================
|
||||
func delete_file(args: Dictionary) -> Dictionary:
|
||||
var path: String = str(args.get(&"path", ""))
|
||||
var confirm: bool = bool(args.get(&"confirm", false))
|
||||
var create_backup: bool = bool(args.get(&"create_backup", true))
|
||||
|
||||
if path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'path'"}
|
||||
if not confirm:
|
||||
return {&"ok": false, &"error": "Must set confirm=true to delete"}
|
||||
|
||||
path = _ensure_res_path(path)
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
return {&"ok": false, &"error": "File not found: " + path}
|
||||
|
||||
# Create backup
|
||||
if create_backup:
|
||||
var backup_path := path + ".bak"
|
||||
DirAccess.copy_absolute(path, backup_path)
|
||||
|
||||
var err := DirAccess.remove_absolute(path)
|
||||
if err != OK:
|
||||
return {&"ok": false, &"error": "Failed to delete file: " + str(err)}
|
||||
|
||||
_refresh_filesystem()
|
||||
|
||||
return {&"ok": true, &"path": path, &"message": "File deleted" + (" (backup created)" if create_backup else "")}
|
||||
|
||||
# =============================================================================
|
||||
# rename_file
|
||||
# =============================================================================
|
||||
func rename_file(args: Dictionary) -> Dictionary:
|
||||
var old_path: String = str(args.get(&"old_path", ""))
|
||||
var new_path: String = str(args.get(&"new_path", ""))
|
||||
|
||||
if old_path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'old_path'"}
|
||||
if new_path.strip_edges().is_empty():
|
||||
return {&"ok": false, &"error": "Missing 'new_path'"}
|
||||
|
||||
old_path = _ensure_res_path(old_path)
|
||||
new_path = _ensure_res_path(new_path)
|
||||
|
||||
if not FileAccess.file_exists(old_path):
|
||||
return {&"ok": false, &"error": "File not found: " + old_path}
|
||||
if FileAccess.file_exists(new_path):
|
||||
return {&"ok": false, &"error": "Target already exists: " + new_path}
|
||||
|
||||
# Ensure target directory exists
|
||||
var dir_path := new_path.get_base_dir()
|
||||
if not DirAccess.dir_exists_absolute(dir_path):
|
||||
DirAccess.make_dir_recursive_absolute(dir_path)
|
||||
|
||||
var err := DirAccess.rename_absolute(old_path, new_path)
|
||||
if err != OK:
|
||||
return {&"ok": false, &"error": "Failed to rename: " + str(err)}
|
||||
|
||||
_refresh_filesystem()
|
||||
|
||||
return {&"ok": true, &"old_path": old_path, &"new_path": new_path,
|
||||
&"message": "Renamed %s to %s" % [old_path, new_path]}
|
||||
@@ -0,0 +1 @@
|
||||
uid://sqjhipl5ff5y
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://cjktr607bngrs
|
||||
@@ -1,31 +0,0 @@
|
||||
# Contributors
|
||||
|
||||
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
|
||||
Studio], and the community. The following is the list of community contributors
|
||||
involved with netfox:
|
||||
|
||||
* Alberto Klocker <albertok@gmail.com>
|
||||
* Andrew Davis <jonandrewdavis@gmail.com>
|
||||
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
|
||||
* Dan Schuman <danschuman@gmail.com>
|
||||
* Dustie <77035922+DustieDog@users.noreply.github.com>
|
||||
* Eric Volpone <ericvolpone@gmail.com>
|
||||
* Gordon MacPherson <gordon@gordonite.tech>
|
||||
* Jake Cattrall <krazyjakee@gmail.com>
|
||||
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
|
||||
* Joseph Michael Ware <9at25jnr3@mozmail.com>
|
||||
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
|
||||
* Nicolas Batty <nicolas.batty@gmail.com>
|
||||
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
|
||||
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
|
||||
* Russ <622740+russ-@users.noreply.github.com>
|
||||
* Ryan Roden-Corrent <github@rcorre.net>
|
||||
* TheYellowArchitect <dmalandris@uth.gr>
|
||||
* TheYellowArchitect <hello@theyellowarchitect.com>
|
||||
* camperotactico <alberto.vgdd@gmail.com>
|
||||
* gk98s <89647115+gk98s@users.noreply.github.com>
|
||||
* zibetnu <9at25jnr3@mozmail.com>
|
||||
|
||||
[netfox]: https://github.com/foxssake/netfox
|
||||
[Fox's Sake Studio]: https://github.com/foxssake/
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bhm6yivew52yv"
|
||||
path="res://.godot/imported/network-rigid-body-2d.svg-c7ef7df16c4383a80b842fa966aa7aea.ctex"
|
||||
metadata={
|
||||
"has_editor_variant": true,
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/netfox.extras/icons/network-rigid-body-2d.svg"
|
||||
dest_files=["res://.godot/imported/network-rigid-body-2d.svg-c7ef7df16c4383a80b842fa966aa7aea.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=true
|
||||
editor/convert_colors_with_editor_theme=true
|
||||
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b2lri7ofmq6a7"
|
||||
path="res://.godot/imported/network-rigid-body-3d.svg-325e3a4834ddab37f186912ac93fa449.ctex"
|
||||
metadata={
|
||||
"has_editor_variant": true,
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/netfox.extras/icons/network-rigid-body-3d.svg"
|
||||
dest_files=["res://.godot/imported/network-rigid-body-3d.svg-325e3a4834ddab37f186912ac93fa449.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=true
|
||||
editor/convert_colors_with_editor_theme=true
|
||||
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cx8j75i6acsic"
|
||||
path="res://.godot/imported/rewindable-state-machine.svg-87ca202891a7e2319363a5e2f5387494.ctex"
|
||||
metadata={
|
||||
"has_editor_variant": true,
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/netfox.extras/icons/rewindable-state-machine.svg"
|
||||
dest_files=["res://.godot/imported/rewindable-state-machine.svg-87ca202891a7e2319363a5e2f5387494.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=true
|
||||
editor/convert_colors_with_editor_theme=true
|
||||
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dsxkv3ufy1r2q"
|
||||
path="res://.godot/imported/rewindable-state.svg-b534e5a6f5de20b3fe0b63d612bd36bf.ctex"
|
||||
metadata={
|
||||
"has_editor_variant": true,
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/netfox.extras/icons/rewindable-state.svg"
|
||||
dest_files=["res://.godot/imported/rewindable-state.svg-b534e5a6f5de20b3fe0b63d612bd36bf.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=true
|
||||
editor/convert_colors_with_editor_theme=true
|
||||
@@ -109,7 +109,8 @@ func _enter_tree():
|
||||
add_setting(setting)
|
||||
|
||||
for autoload in AUTOLOADS:
|
||||
add_autoload_singleton(autoload.name, autoload.path)
|
||||
if not has_autoload(autoload.name):
|
||||
add_autoload_singleton(autoload.name, autoload.path)
|
||||
|
||||
for type in TYPES:
|
||||
add_custom_type(type.name, type.base, load(type.script), load(type.icon))
|
||||
@@ -122,7 +123,8 @@ func _exit_tree():
|
||||
remove_setting(setting)
|
||||
|
||||
for autoload in AUTOLOADS:
|
||||
remove_autoload_singleton(autoload.name)
|
||||
if has_autoload(autoload.name):
|
||||
remove_autoload_singleton(autoload.name)
|
||||
|
||||
for type in TYPES:
|
||||
remove_custom_type(type.name)
|
||||
@@ -148,6 +150,9 @@ func remove_setting(setting: Dictionary):
|
||||
|
||||
ProjectSettings.clear(setting.name)
|
||||
|
||||
func has_autoload(name: String) -> bool:
|
||||
return ProjectSettings.has_setting("autoload/" + name)
|
||||
|
||||
func _render_tool_menu():
|
||||
_free_tool_menu()
|
||||
for driver_toggle in PhysicsDriverToggles.all():
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
|
||||
extends PhysicsDriver
|
||||
|
||||
class_name RapierDriver2D
|
||||
|
||||
var _state: StateManager2D
|
||||
|
||||
var _stored_states: int = 0
|
||||
|
||||
|
||||
func _init_physics_space() -> void:
|
||||
physics_space = get_viewport().world_2d.space
|
||||
PhysicsServer2D.space_set_active(physics_space, false)
|
||||
|
||||
_state = StateManager2D.new()
|
||||
_state.root_node = self
|
||||
_state.set_max_cache_length(ProjectSettings.get_setting("netfox/rollback/history_limit", 64))
|
||||
_state.set_rolling_cache(true)
|
||||
add_child(_state)
|
||||
|
||||
|
||||
func _physics_step(delta) -> void:
|
||||
RapierPhysicsServer2D.space_step(physics_space, delta)
|
||||
RapierPhysicsServer2D.space_flush_queries(physics_space)
|
||||
|
||||
func _snapshot_space(tick: int) -> void:
|
||||
snapshots[tick] = RapierPhysicsServer2D.export_binary(physics_space)
|
||||
|
||||
func _rollback_space(tick) -> void:
|
||||
if snapshots.has(tick):
|
||||
RapierPhysicsServer2D.import_binary(physics_space, snapshots[tick])
|
||||
|
||||
func _snapshot_space(tick: int) -> void:
|
||||
_state.cache_state(physics_space, tick)
|
||||
|
||||
|
||||
func _rollback_space(tick: int) -> void:
|
||||
# With rolling cache, tick states are ordered by age with the newest at 0
|
||||
var offset = NetworkTime.tick - tick
|
||||
if (offset >= _stored_states):
|
||||
return
|
||||
|
||||
_stored_states = min(_stored_states + 1, _state.max_cache_length)
|
||||
_state.load_cached_state(physics_space, offset)
|
||||
|
||||
@@ -2,17 +2,36 @@ extends PhysicsDriver
|
||||
|
||||
class_name RapierDriver3D
|
||||
|
||||
var _state: StateManager3D
|
||||
|
||||
var _stored_states: int = 0
|
||||
|
||||
|
||||
func _init_physics_space() -> void:
|
||||
physics_space = get_viewport().world_3d.space
|
||||
PhysicsServer3D.space_set_active(physics_space, false)
|
||||
|
||||
_state = StateManager3D.new()
|
||||
_state.root_node = self
|
||||
_state.set_max_cache_length(ProjectSettings.get_setting("netfox/rollback/history_limit", 64))
|
||||
_state.set_rolling_cache(true)
|
||||
add_child(_state)
|
||||
|
||||
|
||||
func _physics_step(delta) -> void:
|
||||
RapierPhysicsServer3D.space_step(physics_space, delta)
|
||||
RapierPhysicsServer3D.space_flush_queries(physics_space)
|
||||
|
||||
func _snapshot_space(tick: int) -> void:
|
||||
snapshots[tick] = RapierPhysicsServer3D.export_binary(physics_space)
|
||||
|
||||
func _rollback_space(tick) -> void:
|
||||
if snapshots.has(tick):
|
||||
RapierPhysicsServer3D.import_binary(physics_space, snapshots[tick])
|
||||
|
||||
func _snapshot_space(tick: int) -> void:
|
||||
_state.cache_state(physics_space, tick)
|
||||
|
||||
|
||||
func _rollback_space(tick: int) -> void:
|
||||
# With rolling cache, tick states are ordered by age with the newest at 0
|
||||
var offset = NetworkTime.tick - tick
|
||||
if (offset >= _stored_states):
|
||||
return
|
||||
|
||||
_stored_states = min(_stored_states + 1, _state.max_cache_length)
|
||||
_state.load_cached_state(physics_space, offset)
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
name="netfox.extras"
|
||||
description="Game-specific utilities for Netfox"
|
||||
author="Tamas Galffy and contributors"
|
||||
version="1.35.3"
|
||||
version="1.40.2"
|
||||
script="netfox-extras.gd"
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Contributors
|
||||
|
||||
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
|
||||
Studio], and the community. The following is the list of community contributors
|
||||
involved with netfox:
|
||||
|
||||
* Alberto Klocker <albertok@gmail.com>
|
||||
* Andrew Davis <jonandrewdavis@gmail.com>
|
||||
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
|
||||
* Dan Schuman <danschuman@gmail.com>
|
||||
* Dustie <77035922+DustieDog@users.noreply.github.com>
|
||||
* Eric Volpone <ericvolpone@gmail.com>
|
||||
* Gordon MacPherson <gordon@gordonite.tech>
|
||||
* Jake Cattrall <krazyjakee@gmail.com>
|
||||
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
|
||||
* Joseph Michael Ware <9at25jnr3@mozmail.com>
|
||||
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
|
||||
* Nicolas Batty <nicolas.batty@gmail.com>
|
||||
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
|
||||
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
|
||||
* Russ <622740+russ-@users.noreply.github.com>
|
||||
* Ryan Roden-Corrent <github@rcorre.net>
|
||||
* TheYellowArchitect <dmalandris@uth.gr>
|
||||
* TheYellowArchitect <hello@theyellowarchitect.com>
|
||||
* camperotactico <alberto.vgdd@gmail.com>
|
||||
* gk98s <89647115+gk98s@users.noreply.github.com>
|
||||
* zibetnu <9at25jnr3@mozmail.com>
|
||||
|
||||
[netfox]: https://github.com/foxssake/netfox
|
||||
[Fox's Sake Studio]: https://github.com/foxssake/
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
extends RefCounted
|
||||
class_name _Bitset
|
||||
|
||||
# Stores a list of booleans, representing them efficiently as a PackedByteArray
|
||||
|
||||
var _data: PackedByteArray
|
||||
var _bit_count: int
|
||||
|
||||
static func of_bools(values: Array) -> _Bitset:
|
||||
var result := _Bitset.new(values.size())
|
||||
for i in values.size():
|
||||
if values[i]:
|
||||
result.set_bit(i)
|
||||
return result
|
||||
|
||||
func _init(bit_count: int):
|
||||
var bytes := bit_count / 8
|
||||
if bit_count % 8 > 0:
|
||||
bytes += 1
|
||||
|
||||
_data = PackedByteArray()
|
||||
_data.resize(bytes)
|
||||
|
||||
_bit_count = bit_count
|
||||
|
||||
func bit_count() -> int:
|
||||
return _bit_count
|
||||
|
||||
func is_empty() -> bool:
|
||||
return _bit_count == 0
|
||||
|
||||
func is_not_empty() -> bool:
|
||||
return _bit_count != 0
|
||||
|
||||
func get_bit(idx: int) -> bool:
|
||||
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
|
||||
var byte_idx := idx / 8
|
||||
var bit_idx := idx % 8
|
||||
|
||||
return (_data[byte_idx] >> bit_idx) & 0x1 != 0
|
||||
|
||||
func set_bit(idx: int) -> void:
|
||||
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
|
||||
var byte_idx := idx / 8
|
||||
var bit_idx := idx % 8
|
||||
|
||||
_data[byte_idx] |= 0x1 << bit_idx
|
||||
|
||||
func clear_bit(idx: int) -> void:
|
||||
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
|
||||
var byte_idx := idx / 8
|
||||
var bit_idx := idx % 8
|
||||
|
||||
_data[byte_idx] &= ~(0x1 << bit_idx)
|
||||
|
||||
func toggle_bit(idx: int) -> void:
|
||||
assert(idx < _bit_count, "Accessing bit %d on bitset of size %d!" % [idx, _bit_count])
|
||||
var byte_idx := idx / 8
|
||||
var bit_idx := idx % 8
|
||||
|
||||
_data[byte_idx] ^= 0x1 << bit_idx
|
||||
|
||||
func get_set_indices() -> Array[int]:
|
||||
var result := [] as Array[int]
|
||||
for i in _data.size():
|
||||
var byte := _data[i]
|
||||
|
||||
if byte & 0x01: result.append(i * 8 + 0)
|
||||
if byte & 0x02: result.append(i * 8 + 1)
|
||||
if byte & 0x04: result.append(i * 8 + 2)
|
||||
if byte & 0x08: result.append(i * 8 + 3)
|
||||
|
||||
if byte & 0x10: result.append(i * 8 + 4)
|
||||
if byte & 0x20: result.append(i * 8 + 5)
|
||||
if byte & 0x40: result.append(i * 8 + 6)
|
||||
if byte & 0x80: result.append(i * 8 + 7)
|
||||
return result
|
||||
|
||||
func equals(other) -> bool:
|
||||
if other is _Bitset:
|
||||
return other._bit_count == _bit_count and other._data == _data
|
||||
else:
|
||||
return false
|
||||
|
||||
func _to_string() -> String:
|
||||
if is_empty():
|
||||
return "Bitset(n=0)"
|
||||
else:
|
||||
var body := ""
|
||||
for i in _bit_count:
|
||||
if i != 0 and i % 4 == 0:
|
||||
body += " "
|
||||
if get_bit(i): body += "1"
|
||||
else: body += "0"
|
||||
return "Bitset(n=%d, %s)" % [_bit_count, body]
|
||||
@@ -0,0 +1 @@
|
||||
uid://c46ub48lgga12
|
||||
@@ -0,0 +1,57 @@
|
||||
extends RefCounted
|
||||
class_name _Graph
|
||||
|
||||
# Represents a graph, in the sense of a set of nodes, arbitrarily connected by
|
||||
# links
|
||||
|
||||
var _links_from := {} # `from` to `to[]`
|
||||
var _links_to := {} # `to` to `from[]`
|
||||
|
||||
func link(from: Variant, to: Variant) -> void:
|
||||
if has_link(from, to):
|
||||
return
|
||||
|
||||
_append(_links_from, from, to)
|
||||
_append(_links_to, to, from)
|
||||
|
||||
func unlink(from: Variant, to: Variant) -> void:
|
||||
_erase(_links_from, from, to)
|
||||
_erase(_links_to, to, from)
|
||||
|
||||
func erase(node: Variant) -> void:
|
||||
var links_to := _links_from.get(node, [])
|
||||
var links_from := _links_to.get(node, [])
|
||||
|
||||
_links_from.erase(node)
|
||||
_links_to.erase(node)
|
||||
|
||||
for link in links_to:
|
||||
_erase(_links_to, link, node)
|
||||
|
||||
for link in links_from:
|
||||
_erase(_links_from, link, node)
|
||||
|
||||
func get_linked_from(from: Variant) -> Array:
|
||||
return _links_from.get(from, [])
|
||||
|
||||
func get_linked_to(to: Variant) -> Array:
|
||||
return _links_to.get(to, [])
|
||||
|
||||
func has_link(from: Variant, to: Variant) -> bool:
|
||||
return get_linked_from(from).has(to)
|
||||
|
||||
func _append(pool: Dictionary, key: Variant, value: Variant) -> void:
|
||||
if not pool.has(key):
|
||||
pool[key] = [value]
|
||||
else:
|
||||
pool[key].append(value)
|
||||
|
||||
func _erase(pool: Dictionary, key: Variant, value: Variant) -> void:
|
||||
if not pool.has(key):
|
||||
return
|
||||
|
||||
var values := pool[key] as Array
|
||||
values.erase(value)
|
||||
|
||||
if values.is_empty():
|
||||
pool.erase(key)
|
||||
@@ -0,0 +1 @@
|
||||
uid://djknl4n6akxue
|
||||
@@ -1,70 +1,134 @@
|
||||
extends RefCounted
|
||||
class_name _HistoryBuffer
|
||||
|
||||
# Maps ticks (int) to arbitrary data
|
||||
var _buffer: Dictionary = {}
|
||||
# Maps ticks (int) to arbitrary data, stored in a sliding ring buffer
|
||||
|
||||
func get_snapshot(tick: int):
|
||||
if _buffer.has(tick):
|
||||
return _buffer[tick]
|
||||
else:
|
||||
return null
|
||||
var _capacity := 64
|
||||
var _buffer := []
|
||||
var _previous := []
|
||||
|
||||
func set_snapshot(tick: int, data):
|
||||
_buffer[tick] = data
|
||||
var _tail := 0
|
||||
var _head := 0
|
||||
|
||||
func get_buffer() -> Dictionary:
|
||||
return _buffer
|
||||
static func of(capacity: int, data: Dictionary) -> _HistoryBuffer:
|
||||
var history_buffer := _HistoryBuffer.new(capacity)
|
||||
for idx in data:
|
||||
history_buffer.set_at(idx, data[idx])
|
||||
return history_buffer
|
||||
|
||||
func get_earliest_tick() -> int:
|
||||
return _buffer.keys().min()
|
||||
func _init(capacity: int = 64):
|
||||
_capacity = capacity
|
||||
_buffer.resize(_capacity)
|
||||
_previous.resize(_capacity)
|
||||
|
||||
func get_latest_tick() -> int:
|
||||
return _buffer.keys().max()
|
||||
func duplicate(deep: bool = false) -> _HistoryBuffer:
|
||||
var buffer := _HistoryBuffer.new(_capacity)
|
||||
|
||||
func get_closest_tick(tick: int) -> int:
|
||||
if _buffer.has(tick):
|
||||
return tick
|
||||
buffer._buffer = _buffer.duplicate(deep)
|
||||
buffer._previous = _previous.duplicate()
|
||||
buffer._tail = _tail
|
||||
buffer._head = _head
|
||||
|
||||
if _buffer.is_empty():
|
||||
return -1
|
||||
return buffer
|
||||
|
||||
var earliest_tick = get_earliest_tick()
|
||||
if tick < earliest_tick:
|
||||
return earliest_tick
|
||||
func push(value: Variant) -> void:
|
||||
_buffer[_head % _capacity] = value
|
||||
_previous[_head % _capacity] = _head
|
||||
_head += 1
|
||||
_tail += maxi(0, size() - capacity())
|
||||
|
||||
var latest_tick = get_latest_tick()
|
||||
if tick > latest_tick:
|
||||
return latest_tick
|
||||
func pop() -> Variant:
|
||||
assert(is_not_empty(), "History buffer is empty!")
|
||||
|
||||
return _buffer.keys() \
|
||||
.filter(func (key): return key < tick) \
|
||||
.max()
|
||||
var value = _buffer[_tail % _capacity]
|
||||
_tail += 1
|
||||
return value
|
||||
|
||||
func get_history(tick: int):
|
||||
var closest_tick = get_closest_tick(tick)
|
||||
return _buffer.get(closest_tick)
|
||||
func set_at(at: int, value: Variant) -> void:
|
||||
# Why does this need so many branches?
|
||||
if is_empty():
|
||||
# Buffer is empty, jump to specified index
|
||||
_tail = at
|
||||
_head = at
|
||||
push(value)
|
||||
elif at < _head - capacity():
|
||||
# Trying to set something that would wrap back around and overwrite
|
||||
# current data
|
||||
return
|
||||
elif at == _head:
|
||||
# Simply adding a new item
|
||||
push(value)
|
||||
elif at < _head:
|
||||
_buffer[at % _capacity] = value
|
||||
# Update prev-buffer
|
||||
for i in range(at, _head):
|
||||
if _previous[i % _capacity] == i:
|
||||
break
|
||||
_previous[i % _capacity] = at
|
||||
_tail = mini(_tail, at)
|
||||
elif at >= _head + _capacity:
|
||||
# We're leaving all data behind
|
||||
_tail = at
|
||||
_head = at
|
||||
|
||||
func trim(earliest_tick_to_keep: int):
|
||||
var ticks := _buffer.keys()
|
||||
for tick in ticks:
|
||||
if tick < earliest_tick_to_keep:
|
||||
_buffer.erase(tick)
|
||||
_previous.fill(null)
|
||||
_buffer.fill(null)
|
||||
|
||||
func clear():
|
||||
_buffer.clear()
|
||||
push(value)
|
||||
elif at >= _head:
|
||||
# Skipping forward a bit
|
||||
var previous := _head - 1
|
||||
while _head < at:
|
||||
_previous[_head % _capacity] = previous
|
||||
_head += 1
|
||||
_tail += maxi(0, size() - _capacity)
|
||||
|
||||
push(value)
|
||||
|
||||
func has_at(at: int) -> bool:
|
||||
if is_empty(): return false
|
||||
if at < _head - capacity(): return false
|
||||
if at >= _head: return false
|
||||
return _previous[at % _capacity] == at
|
||||
|
||||
func get_at(at: int, default: Variant = null) -> Variant:
|
||||
if not has_at(at):
|
||||
return default
|
||||
return _buffer[at % _capacity]
|
||||
|
||||
func has_latest_at(at: int) -> bool:
|
||||
if is_empty(): return false
|
||||
if at < _tail: return false
|
||||
return true
|
||||
|
||||
func size() -> int:
|
||||
return _buffer.size()
|
||||
return _head - _tail
|
||||
|
||||
func capacity() -> int:
|
||||
return _capacity
|
||||
|
||||
func get_earliest_index() -> int:
|
||||
return _tail
|
||||
|
||||
func get_latest_index() -> int:
|
||||
return _head - 1
|
||||
|
||||
func get_latest_index_at(at: int) -> int:
|
||||
if not has_latest_at(at):
|
||||
return -1
|
||||
if at >= _head:
|
||||
return get_latest_index()
|
||||
|
||||
return _previous[at % _capacity]
|
||||
|
||||
func get_latest_at(at: int) -> Variant:
|
||||
return get_at(get_latest_index_at(at))
|
||||
|
||||
func clear():
|
||||
_tail = _head
|
||||
|
||||
func is_empty() -> bool:
|
||||
return _buffer.is_empty()
|
||||
return size() == 0
|
||||
|
||||
func has(tick) -> bool:
|
||||
return _buffer.has(tick)
|
||||
|
||||
func ticks() -> Array:
|
||||
return _buffer.keys()
|
||||
|
||||
func erase(tick):
|
||||
_buffer.erase(tick)
|
||||
func is_not_empty() -> bool:
|
||||
return not is_empty()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
extends RefCounted
|
||||
class_name _IntervalScheduler
|
||||
|
||||
# Returns true on every nth `is_now()` call
|
||||
|
||||
var interval := 1
|
||||
var _idx := 0
|
||||
|
||||
func _init(p_interval: int = 1):
|
||||
interval = p_interval
|
||||
|
||||
func is_now() -> bool:
|
||||
if interval <= 0:
|
||||
return false
|
||||
elif interval == 1:
|
||||
return true
|
||||
elif _idx + 1 >= interval:
|
||||
_idx = 0
|
||||
return true
|
||||
else:
|
||||
_idx += 1
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://egfuyvoj0r2s
|
||||
@@ -3,5 +3,5 @@
|
||||
name="netfox.internals"
|
||||
description="Shared internals for netfox addons"
|
||||
author="Tamas Galffy and contributors"
|
||||
version="1.35.3"
|
||||
version="1.40.2"
|
||||
script="plugin.gd"
|
||||
|
||||
@@ -10,6 +10,11 @@ static func of(items: Array) -> _Set:
|
||||
result.add(item)
|
||||
return result
|
||||
|
||||
func duplicate(deep: bool = false) -> _Set:
|
||||
var result := _Set.new()
|
||||
result._data = _data.duplicate(deep)
|
||||
return result
|
||||
|
||||
func add(value):
|
||||
_data[value] = true
|
||||
|
||||
@@ -46,6 +51,9 @@ func equals(other) -> bool:
|
||||
func _to_string():
|
||||
return "Set" + str(values())
|
||||
|
||||
func _to_vest():
|
||||
return _data.keys()
|
||||
|
||||
func _iter_init(arg) -> bool:
|
||||
_iterator_idx = 0
|
||||
return _can_iterate()
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Contributors
|
||||
|
||||
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
|
||||
Studio], and the community. The following is the list of community contributors
|
||||
involved with netfox:
|
||||
|
||||
* Alberto Klocker <albertok@gmail.com>
|
||||
* Andrew Davis <jonandrewdavis@gmail.com>
|
||||
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
|
||||
* Dan Schuman <danschuman@gmail.com>
|
||||
* Dustie <77035922+DustieDog@users.noreply.github.com>
|
||||
* Eric Volpone <ericvolpone@gmail.com>
|
||||
* Gordon MacPherson <gordon@gordonite.tech>
|
||||
* Jake Cattrall <krazyjakee@gmail.com>
|
||||
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
|
||||
* Joseph Michael Ware <9at25jnr3@mozmail.com>
|
||||
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
|
||||
* Nicolas Batty <nicolas.batty@gmail.com>
|
||||
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
|
||||
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
|
||||
* Russ <622740+russ-@users.noreply.github.com>
|
||||
* Ryan Roden-Corrent <github@rcorre.net>
|
||||
* TheYellowArchitect <dmalandris@uth.gr>
|
||||
* TheYellowArchitect <hello@theyellowarchitect.com>
|
||||
* camperotactico <alberto.vgdd@gmail.com>
|
||||
* gk98s <89647115+gk98s@users.noreply.github.com>
|
||||
* zibetnu <9at25jnr3@mozmail.com>
|
||||
|
||||
[netfox]: https://github.com/foxssake/netfox
|
||||
[Fox's Sake Studio]: https://github.com/foxssake/
|
||||
|
||||
@@ -20,7 +20,7 @@ See the root [README](../../README.md).
|
||||
|
||||
## Usage
|
||||
|
||||
See the [docs](https://foxssake.github.io/netfox/netfox.noray/guides/noray/).
|
||||
See the [docs](https://foxssake.github.io/netfox/latest/netfox.noray/guides/noray/).
|
||||
|
||||
For a full example, see [noray-bootstrapper.gd].
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ func _enter_tree():
|
||||
add_setting(setting)
|
||||
|
||||
for autoload in AUTOLOADS:
|
||||
add_autoload_singleton(autoload.name, autoload.path)
|
||||
if not has_autoload(autoload.name):
|
||||
add_autoload_singleton(autoload.name, autoload.path)
|
||||
|
||||
func _exit_tree():
|
||||
if ProjectSettings.get_setting("netfox/general/clear_settings", false):
|
||||
@@ -31,7 +32,8 @@ func _exit_tree():
|
||||
remove_setting(setting)
|
||||
|
||||
for autoload in AUTOLOADS:
|
||||
remove_autoload_singleton(autoload.name)
|
||||
if has_autoload(autoload.name):
|
||||
remove_autoload_singleton(autoload.name)
|
||||
|
||||
func add_setting(setting: Dictionary):
|
||||
if ProjectSettings.has_setting(setting.name):
|
||||
@@ -51,3 +53,6 @@ func remove_setting(setting: Dictionary):
|
||||
return
|
||||
|
||||
ProjectSettings.clear(setting.name)
|
||||
|
||||
func has_autoload(name: String) -> bool:
|
||||
return ProjectSettings.has_setting("autoload/" + name)
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
name="netfox.noray"
|
||||
description="Bulletproof your connectivity with noray integration for netfox"
|
||||
author="Tamas Galffy and contributors"
|
||||
version="1.35.3"
|
||||
version="1.40.2"
|
||||
script="netfox-noray.gd"
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
# Contributors
|
||||
|
||||
This addon, and the entirety of [netfox] is a shared effort of [Fox's Sake
|
||||
Studio], and the community. The following is the list of community contributors
|
||||
involved with netfox:
|
||||
|
||||
* Alberto Klocker <albertok@gmail.com>
|
||||
* Andrew Davis <jonandrewdavis@gmail.com>
|
||||
* Bryan Lee <42545742+bryanmylee@users.noreply.github.com>
|
||||
* Dan Schuman <danschuman@gmail.com>
|
||||
* Dustie <77035922+DustieDog@users.noreply.github.com>
|
||||
* Eric Volpone <ericvolpone@gmail.com>
|
||||
* Gordon MacPherson <gordon@gordonite.tech>
|
||||
* Jake Cattrall <krazyjakee@gmail.com>
|
||||
* Jon Stevens <1030863+jonstvns@users.noreply.github.com>
|
||||
* Joseph Michael Ware <9at25jnr3@mozmail.com>
|
||||
* Matias Kumpulainen <kumpulainen.matias@gmail.com>
|
||||
* Nicolas Batty <nicolas.batty@gmail.com>
|
||||
* Ricky <77863853+RickyYCheng@users.noreply.github.com>
|
||||
* Riordan-DC <44295008+Riordan-DC@users.noreply.github.com>
|
||||
* Russ <622740+russ-@users.noreply.github.com>
|
||||
* Ryan Roden-Corrent <github@rcorre.net>
|
||||
* TheYellowArchitect <dmalandris@uth.gr>
|
||||
* TheYellowArchitect <hello@theyellowarchitect.com>
|
||||
* camperotactico <alberto.vgdd@gmail.com>
|
||||
* gk98s <89647115+gk98s@users.noreply.github.com>
|
||||
* zibetnu <9at25jnr3@mozmail.com>
|
||||
|
||||
[netfox]: https://github.com/foxssake/netfox
|
||||
[Fox's Sake Studio]: https://github.com/foxssake/
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
extends RefCounted
|
||||
class_name _DiffHistoryEncoder
|
||||
|
||||
var _history: _PropertyHistoryBuffer
|
||||
var _property_cache: PropertyCache
|
||||
|
||||
var _full_snapshot := {}
|
||||
var _encoded_snapshot := {}
|
||||
|
||||
var _property_indexes := _BiMap.new()
|
||||
|
||||
var _version := 0
|
||||
var _has_received := false
|
||||
|
||||
static var _logger := NetfoxLogger._for_netfox("DiffHistoryEncoder")
|
||||
|
||||
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
|
||||
_history = p_history
|
||||
_property_cache = p_property_cache
|
||||
|
||||
func add_properties(properties: Array[PropertyEntry]) -> void:
|
||||
var has_new_properties := false
|
||||
|
||||
for property_entry in properties:
|
||||
var is_new := _ensure_property_idx(property_entry.to_string())
|
||||
has_new_properties = has_new_properties or is_new
|
||||
|
||||
# If we added any new properties, increment version
|
||||
if has_new_properties:
|
||||
_version = (_version + 1) % 256
|
||||
|
||||
func encode(tick: int, reference_tick: int, properties: Array[PropertyEntry]) -> PackedByteArray:
|
||||
assert(properties.size() <= 255, "Property indices may not fit into bytes!")
|
||||
|
||||
var snapshot := _history.get_snapshot(tick)
|
||||
var property_strings := properties.map(func(it): return it.to_string())
|
||||
|
||||
var reference_snapshot := _history.get_history(reference_tick)
|
||||
var diff_snapshot := reference_snapshot.make_patch(snapshot)
|
||||
|
||||
_full_snapshot = snapshot.as_dictionary()
|
||||
_encoded_snapshot = diff_snapshot.as_dictionary()
|
||||
|
||||
if diff_snapshot.is_empty():
|
||||
return PackedByteArray()
|
||||
|
||||
var buffer := StreamPeerBuffer.new()
|
||||
buffer.put_u8(_version)
|
||||
|
||||
for property in diff_snapshot.properties():
|
||||
var property_idx := _property_indexes.get_by_value(property) as int
|
||||
var property_value = diff_snapshot.get_value(property)
|
||||
|
||||
buffer.put_u8(property_idx)
|
||||
buffer.put_var(property_value)
|
||||
|
||||
return buffer.data_array
|
||||
|
||||
func decode(data: PackedByteArray, properties: Array[PropertyEntry]) -> _PropertySnapshot:
|
||||
var result := _PropertySnapshot.new()
|
||||
|
||||
if data.is_empty():
|
||||
return result
|
||||
|
||||
var buffer := StreamPeerBuffer.new()
|
||||
buffer.data_array = data
|
||||
|
||||
var packet_version := buffer.get_u8()
|
||||
if packet_version != _version:
|
||||
if not _has_received:
|
||||
# This is the first time we receive data
|
||||
# Assume the version is OK
|
||||
_version = packet_version
|
||||
else:
|
||||
# Since we don't remove entries, only add, we can still parse what
|
||||
# we can
|
||||
_logger.warning("Property config version mismatch - own %d != received %d", [_version, packet_version])
|
||||
|
||||
_has_received = true
|
||||
|
||||
while buffer.get_available_bytes() > 0:
|
||||
var property_idx := buffer.get_u8()
|
||||
var property_value := buffer.get_var()
|
||||
if not _property_indexes.has_key(property_idx):
|
||||
_logger.warning("Received unknown property index %d, ignoring!", [property_idx])
|
||||
continue
|
||||
|
||||
var property_entry := _property_indexes.get_by_key(property_idx)
|
||||
result.set_value(property_entry, property_value)
|
||||
|
||||
return result
|
||||
|
||||
func apply(tick: int, snapshot: _PropertySnapshot, reference_tick: int, sender: int = -1) -> bool:
|
||||
if tick < NetworkRollback.history_start:
|
||||
# State too old!
|
||||
_logger.error(
|
||||
"Received diff snapshot for @%d, rejecting because older than %s frames",
|
||||
[tick, NetworkRollback.history_limit]
|
||||
)
|
||||
return false
|
||||
|
||||
if snapshot.is_empty():
|
||||
return true
|
||||
|
||||
if sender > 0:
|
||||
snapshot.sanitize(sender, _property_cache)
|
||||
if snapshot.is_empty():
|
||||
_logger.warning("Received invalid diff from #%s for @%s", [sender, tick])
|
||||
return false
|
||||
|
||||
if not _history.has(reference_tick):
|
||||
# Reference tick missing, hope for the best
|
||||
_logger.warning("Reference tick %d missing for #%s applying %d", [reference_tick, sender, tick])
|
||||
|
||||
var reference_snapshot := _history.get_snapshot(reference_tick)
|
||||
_history.set_snapshot(tick, reference_snapshot.merge(snapshot))
|
||||
return true
|
||||
|
||||
# TODO: Rework metrics so these are not needed
|
||||
func get_encoded_snapshot() -> Dictionary:
|
||||
return _encoded_snapshot
|
||||
|
||||
func get_full_snapshot() -> Dictionary:
|
||||
return _full_snapshot
|
||||
|
||||
func _ensure_property_idx(property: String) -> bool:
|
||||
if _property_indexes.has_value(property):
|
||||
return false
|
||||
|
||||
assert(_property_indexes.size() < 256, "Property index map is full, can't add new property!")
|
||||
var idx := hash(property) % 256
|
||||
while _property_indexes.has_key(idx):
|
||||
idx = hash(idx + 1) % 256
|
||||
_property_indexes.put(idx, property)
|
||||
|
||||
return true
|
||||
@@ -1 +0,0 @@
|
||||
uid://dc73evbedbmvs
|
||||
@@ -1,116 +0,0 @@
|
||||
extends RefCounted
|
||||
class_name _RedundantHistoryEncoder
|
||||
|
||||
var redundancy: int = 4:
|
||||
get = get_redundancy,
|
||||
set = set_redundancy
|
||||
|
||||
var _history: _PropertyHistoryBuffer
|
||||
var _properties: Array[PropertyEntry]
|
||||
var _property_cache: PropertyCache
|
||||
|
||||
var _version := 0
|
||||
var _has_received := false
|
||||
|
||||
var _logger := NetfoxLogger._for_netfox("RedundantHistoryEncoder")
|
||||
|
||||
func get_redundancy() -> int:
|
||||
return redundancy
|
||||
|
||||
func set_redundancy(p_redundancy: int):
|
||||
if p_redundancy <= 0:
|
||||
_logger.warning(
|
||||
"Attempting to set redundancy to %d, which would send no data!", [p_redundancy]
|
||||
)
|
||||
return
|
||||
|
||||
redundancy = p_redundancy
|
||||
|
||||
func set_properties(properties: Array[PropertyEntry]) -> void:
|
||||
if _properties != properties:
|
||||
_version = (_version + 1) % 256
|
||||
_properties = properties.duplicate()
|
||||
|
||||
func encode(tick: int, properties: Array[PropertyEntry]) -> Array:
|
||||
if _history.is_empty():
|
||||
return []
|
||||
var data := []
|
||||
|
||||
for i in range(mini(redundancy, _history.size())):
|
||||
var offset_tick := tick - i
|
||||
if offset_tick < _history.get_earliest_tick():
|
||||
break
|
||||
|
||||
var snapshot := _history.get_snapshot(offset_tick)
|
||||
for property in properties:
|
||||
data.append(snapshot.get_value(property.to_string()))
|
||||
|
||||
data.append(_version)
|
||||
return data
|
||||
|
||||
func decode(data: Array, properties: Array[PropertyEntry]) -> Array[_PropertySnapshot]:
|
||||
if data.is_empty() or properties.is_empty():
|
||||
return []
|
||||
|
||||
var packet_version := data.pop_back() as int
|
||||
|
||||
if packet_version != _version:
|
||||
if not _has_received:
|
||||
# First packet, assume version is OK
|
||||
_version = packet_version
|
||||
else:
|
||||
# Version mismatch, can't parse
|
||||
_logger.warning("Version mismatch! own: %d, received: %s", [_version, packet_version])
|
||||
return []
|
||||
|
||||
var result: Array[_PropertySnapshot] = []
|
||||
var redundancy := data.size() / properties.size()
|
||||
result.assign(range(redundancy)
|
||||
.map(func(__): return _PropertySnapshot.new())
|
||||
)
|
||||
|
||||
for i in range(data.size()):
|
||||
var offset_idx := i / properties.size()
|
||||
var prop_idx := i % properties.size()
|
||||
|
||||
result[offset_idx].set_value(properties[prop_idx].to_string(), data[i])
|
||||
|
||||
_has_received = true
|
||||
|
||||
return result
|
||||
|
||||
# Returns earliest new tick as int, or -1 if no new ticks applied
|
||||
func apply(tick: int, snapshots: Array[_PropertySnapshot], sender: int = 0) -> int:
|
||||
var earliest_new_tick = -1
|
||||
|
||||
for i in range(snapshots.size()):
|
||||
var offset_tick := tick - i
|
||||
var snapshot := snapshots[i]
|
||||
|
||||
if offset_tick < NetworkRollback.history_start:
|
||||
# Data too old
|
||||
_logger.warning(
|
||||
"Received data for %s, rejecting because older than %s frames",
|
||||
[offset_tick, NetworkRollback.history_limit]
|
||||
)
|
||||
continue
|
||||
|
||||
if sender > 0:
|
||||
snapshot.sanitize(sender, _property_cache)
|
||||
if snapshot.is_empty():
|
||||
# No valid properties ( probably after sanitize )
|
||||
_logger.warning("Received invalid data from %d for tick %d", [sender, tick])
|
||||
continue
|
||||
|
||||
var known_snapshot := _history.get_snapshot(offset_tick)
|
||||
if not known_snapshot.equals(snapshot):
|
||||
# Received a new snapshot, store and emit signal
|
||||
_history.set_snapshot(offset_tick, snapshot)
|
||||
earliest_new_tick = offset_tick
|
||||
|
||||
return earliest_new_tick
|
||||
|
||||
|
||||
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
|
||||
_history = p_history
|
||||
_property_cache = p_property_cache
|
||||
@@ -1 +0,0 @@
|
||||
uid://ytn07qahpatv
|
||||
@@ -1,67 +0,0 @@
|
||||
extends RefCounted
|
||||
class_name _SnapshotHistoryEncoder
|
||||
|
||||
var _history: _PropertyHistoryBuffer
|
||||
var _property_cache: PropertyCache
|
||||
var _properties: Array[PropertyEntry]
|
||||
|
||||
var _version := -1
|
||||
var _has_received := false
|
||||
|
||||
static var _logger := NetfoxLogger._for_netfox("_SnapshotHistoryEncoder")
|
||||
|
||||
func _init(p_history: _PropertyHistoryBuffer, p_property_cache: PropertyCache):
|
||||
_history = p_history
|
||||
_property_cache = p_property_cache
|
||||
|
||||
func set_properties(properties: Array[PropertyEntry]) -> void:
|
||||
if _properties != properties:
|
||||
_version = (_version + 1) % 256
|
||||
_properties = properties.duplicate()
|
||||
|
||||
func encode(tick: int, properties: Array[PropertyEntry]) -> Array:
|
||||
var snapshot := _history.get_snapshot(tick)
|
||||
var data := []
|
||||
data.resize(properties.size())
|
||||
|
||||
for i in range(properties.size()):
|
||||
data[i] = snapshot.get_value(properties[i].to_string())
|
||||
data.append(_version)
|
||||
|
||||
return data
|
||||
|
||||
func decode(data: Array, properties: Array[PropertyEntry]) -> _PropertySnapshot:
|
||||
var result := _PropertySnapshot.new()
|
||||
var packet_version = data.pop_back()
|
||||
|
||||
if packet_version != _version:
|
||||
if not _has_received:
|
||||
# First packet, assume version is OK
|
||||
_version = packet_version
|
||||
else:
|
||||
# Version mismatch, can't parse
|
||||
_logger.warning("Version mismatch! own: %d, received: %s", [_version, packet_version])
|
||||
return result
|
||||
|
||||
if properties.size() != data.size():
|
||||
_logger.warning("Received snapshot with %d entries, with %d known - parsing as much as possible", [data.size(), properties.size()])
|
||||
|
||||
for i in range(0, mini(data.size(), properties.size())):
|
||||
result.set_value(properties[i].to_string(), data[i])
|
||||
|
||||
_has_received = true
|
||||
|
||||
return result
|
||||
|
||||
func apply(tick: int, snapshot: _PropertySnapshot, sender: int = -1) -> bool:
|
||||
if tick < NetworkRollback.history_start:
|
||||
# State too old!
|
||||
_logger.error("Received full snapshot for %s, rejecting because older than %s frames", [tick, NetworkRollback.history_limit])
|
||||
return false
|
||||
|
||||
if sender > 0:
|
||||
snapshot.sanitize(sender, _property_cache)
|
||||
if snapshot.is_empty(): return false
|
||||
|
||||
_history.set_snapshot(tick, snapshot)
|
||||
return true
|
||||
@@ -1 +0,0 @@
|
||||
uid://dwhlghjsgdy4o
|
||||
@@ -0,0 +1,124 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 16 16"
|
||||
xml:space="preserve"
|
||||
sodipodi:docname="predictive-synchronizer.svg"
|
||||
width="16"
|
||||
height="16"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs1"><inkscape:path-effect
|
||||
effect="fillet_chamfer"
|
||||
id="path-effect2"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
nodesatellites_param="F,0,0,1,0,1,0,1 @ F,0,0,1,0,1,0,1 @ F,0,0,1,0,1,0,1 @ F,0,0,1,0,1,0,1"
|
||||
radius="1"
|
||||
unit="px"
|
||||
method="auto"
|
||||
mode="F"
|
||||
chamfer_steps="1"
|
||||
flexible="false"
|
||||
use_knot_distance="true"
|
||||
apply_no_radius="true"
|
||||
apply_with_radius="true"
|
||||
only_selected="false"
|
||||
hide_knots="false" /><inkscape:path-effect
|
||||
effect="fillet_chamfer"
|
||||
id="path-effect1"
|
||||
is_visible="true"
|
||||
lpeversion="1"
|
||||
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
|
||||
radius="0"
|
||||
unit="px"
|
||||
method="auto"
|
||||
mode="F"
|
||||
chamfer_steps="1"
|
||||
flexible="false"
|
||||
use_knot_distance="true"
|
||||
apply_no_radius="true"
|
||||
apply_with_radius="true"
|
||||
only_selected="false"
|
||||
hide_knots="false" /></defs><sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
showgrid="false"
|
||||
inkscape:zoom="24.40625"
|
||||
inkscape:cx="8.8297055"
|
||||
inkscape:cy="10.140845"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1368"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_1"><inkscape:grid
|
||||
id="grid1"
|
||||
units="px"
|
||||
originx="0"
|
||||
originy="0"
|
||||
spacingx="1"
|
||||
spacingy="1"
|
||||
empcolor="#0099e5"
|
||||
empopacity="0.30196078"
|
||||
color="#0099e5"
|
||||
opacity="0.14902"
|
||||
empspacing="5"
|
||||
enabled="true"
|
||||
visible="false" /></sodipodi:namedview>
|
||||
<style
|
||||
type="text/css"
|
||||
id="style1">
|
||||
.st0{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;}
|
||||
.st1{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.66;}
|
||||
.st2{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33;}
|
||||
.st3{fill:#5FFF97;}
|
||||
.st4{fill:#FF5F5F;}
|
||||
</style>
|
||||
<path
|
||||
id="path6"
|
||||
class="st0"
|
||||
d="M 8,8 10.425,5.575"
|
||||
style="stroke-width:2;stroke-dasharray:none" />
|
||||
<path
|
||||
id="path7"
|
||||
class="st1"
|
||||
d="M 8,8 V 4.575"
|
||||
style="stroke-width:2;stroke-dasharray:none" />
|
||||
<path
|
||||
id="path8"
|
||||
class="st2"
|
||||
d="M 8,8 5.575,5.575"
|
||||
style="stroke-width:2;stroke-dasharray:none" />
|
||||
<circle
|
||||
id="path4"
|
||||
class="st0"
|
||||
cx="8"
|
||||
cy="8"
|
||||
style="stroke-width:2;stroke-dasharray:none"
|
||||
r="5" />
|
||||
|
||||
|
||||
<path
|
||||
style="fill:#6f8a91"
|
||||
id="rect1"
|
||||
width="8"
|
||||
height="1.8"
|
||||
x="4.4037995"
|
||||
y="11.704836"
|
||||
sodipodi:type="rect"
|
||||
d="M 5.4037995,11.704836 H 11.4038 a 1,1 45 0 1 1,1 0.90553849,0.90553849 141.34019 0 1 -1,0.8 H 5.4037995 a 1,1 45 0 1 -1,-1 0.90553849,0.90553849 141.34019 0 1 1,-0.8 z"
|
||||
inkscape:path-effect="#path-effect2"
|
||||
transform="translate(-0.40379953,-2.1992974)" /></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dsr2mjcr1lh6v"
|
||||
path="res://.godot/imported/predictive-synchronizer.svg-c1a256fe714777cb768d64b354a43e2b.ctex"
|
||||
metadata={
|
||||
"has_editor_variant": true,
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/netfox/icons/predictive-synchronizer.svg"
|
||||
dest_files=["res://.godot/imported/predictive-synchronizer.svg-c1a256fe714777cb768d64b354a43e2b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=true
|
||||
editor/convert_colors_with_editor_theme=true
|
||||
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dy3qj4jbh0enm"
|
||||
path="res://.godot/imported/rewindable-action.svg-2eb32b564a2fe4ecb8b77ec5b55683ab.ctex"
|
||||
metadata={
|
||||
"has_editor_variant": true,
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/netfox/icons/rewindable-action.svg"
|
||||
dest_files=["res://.godot/imported/rewindable-action.svg-2eb32b564a2fe4ecb8b77ec5b55683ab.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=true
|
||||
editor/convert_colors_with_editor_theme=true
|
||||
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://csg26ysqqb4xe"
|
||||
path="res://.godot/imported/rollback-synchronizer.svg-99c6071e1009de5a35a481b2f486c380.ctex"
|
||||
metadata={
|
||||
"has_editor_variant": true,
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/netfox/icons/rollback-synchronizer.svg"
|
||||
dest_files=["res://.godot/imported/rollback-synchronizer.svg-99c6071e1009de5a35a481b2f486c380.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=true
|
||||
editor/convert_colors_with_editor_theme=true
|
||||
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ogbi1hffcoyh"
|
||||
path="res://.godot/imported/state-synchronizer.svg-9cb9447ba79f114a58e468a24d17b860.ctex"
|
||||
metadata={
|
||||
"has_editor_variant": true,
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/netfox/icons/state-synchronizer.svg"
|
||||
dest_files=["res://.godot/imported/state-synchronizer.svg-9cb9447ba79f114a58e468a24d17b860.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=true
|
||||
editor/convert_colors_with_editor_theme=true
|
||||
@@ -0,0 +1,44 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cpaqxcohxtb68"
|
||||
path="res://.godot/imported/tick-interpolator.svg-c60124cd7d287f516c89a6022efef330.ctex"
|
||||
metadata={
|
||||
"has_editor_variant": true,
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://addons/netfox/icons/tick-interpolator.svg"
|
||||
dest_files=["res://.godot/imported/tick-interpolator.svg-c60124cd7d287f516c89a6022efef330.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=true
|
||||
editor/convert_colors_with_editor_theme=true
|
||||
+73
-7
@@ -10,6 +10,11 @@ var SETTINGS: Array[Dictionary] = [
|
||||
"value": true,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
{
|
||||
"name": "netfox/general/use_raw_commands",
|
||||
"value": false,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
# Logging
|
||||
NetfoxLogger._make_setting("netfox/logging/netfox_log_level"),
|
||||
# Time settings
|
||||
@@ -112,11 +117,41 @@ var SETTINGS: Array[Dictionary] = [
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "0,4,or_greater"
|
||||
},
|
||||
{
|
||||
"name": "netfox/rollback/enable_input_broadcast",
|
||||
"value": false,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
{
|
||||
"name": "netfox/rollback/enable_diff_states",
|
||||
"value": true,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
{
|
||||
"name": "netfox/rollback/full_state_interval",
|
||||
"value": 24,
|
||||
"type": TYPE_INT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "0,60,or_greater"
|
||||
},
|
||||
# StateSynchronizer
|
||||
{
|
||||
"name": "netfox/state_synchronizer/enable_diff_states",
|
||||
"value": true,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
{
|
||||
"name": "netfox/state_synchronizer/history_limit",
|
||||
"value": 64,
|
||||
"type": TYPE_INT
|
||||
},
|
||||
{
|
||||
"name": "netfox/state_synchronizer/full_state_interval",
|
||||
"value": 24,
|
||||
"type": TYPE_INT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "0,60,or_greater"
|
||||
},
|
||||
# Events
|
||||
{
|
||||
"name": "netfox/events/enabled",
|
||||
@@ -145,6 +180,26 @@ const AUTOLOADS: Array[Dictionary] = [
|
||||
{
|
||||
"name": "NetworkPerformance",
|
||||
"path": ROOT + "/network-performance.gd"
|
||||
},
|
||||
{
|
||||
"name": "RollbackSimulationServer",
|
||||
"path": ROOT + "/servers/rollback-simulation-server.gd"
|
||||
},
|
||||
{
|
||||
"name": "NetworkHistoryServer",
|
||||
"path": ROOT + "/servers/network-history-server.gd"
|
||||
},
|
||||
{
|
||||
"name": "NetworkSynchronizationServer",
|
||||
"path": ROOT + "/servers/network-synchronization-server.gd"
|
||||
},
|
||||
{
|
||||
"name": "NetworkIdentityServer",
|
||||
"path": ROOT + "/servers/network-identity-server.gd"
|
||||
},
|
||||
{
|
||||
"name": "NetworkCommandServer",
|
||||
"path": ROOT + "/servers/network-command-server.gd"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -173,15 +228,22 @@ const TYPES: Array[Dictionary] = [
|
||||
"script": ROOT + "/rewindable-action.gd",
|
||||
"icon": ROOT + "/icons/rewindable-action.svg"
|
||||
},
|
||||
{
|
||||
"name": "PredictiveSynchronizer",
|
||||
"base": "Node",
|
||||
"script": ROOT + "/rollback/predictive-synchronizer.gd",
|
||||
"icon": ROOT + "/icons/predictive-synchronizer.svg"
|
||||
},
|
||||
]
|
||||
|
||||
func _enter_tree():
|
||||
for setting in SETTINGS:
|
||||
add_setting(setting)
|
||||
|
||||
|
||||
for autoload in AUTOLOADS:
|
||||
add_autoload_singleton(autoload.name, autoload.path)
|
||||
|
||||
if not has_autoload(autoload.name):
|
||||
add_autoload_singleton(autoload.name, autoload.path)
|
||||
|
||||
for type in TYPES:
|
||||
add_custom_type(type.name, type.base, load(type.script), load(type.icon))
|
||||
|
||||
@@ -189,10 +251,11 @@ func _exit_tree() -> void:
|
||||
if ProjectSettings.get_setting(&"netfox/general/clear_settings", false):
|
||||
for setting in SETTINGS:
|
||||
remove_setting(setting)
|
||||
|
||||
|
||||
for autoload in AUTOLOADS:
|
||||
remove_autoload_singleton(autoload.name)
|
||||
|
||||
if has_autoload(autoload.name):
|
||||
remove_autoload_singleton(autoload.name)
|
||||
|
||||
for type in TYPES:
|
||||
remove_custom_type(type.name)
|
||||
|
||||
@@ -212,5 +275,8 @@ func add_setting(setting: Dictionary) -> void:
|
||||
func remove_setting(setting: Dictionary) -> void:
|
||||
if not ProjectSettings.has_setting(setting.name):
|
||||
return
|
||||
|
||||
|
||||
ProjectSettings.clear(setting.name)
|
||||
|
||||
func has_autoload(name: String) -> bool:
|
||||
return ProjectSettings.has_setting("autoload/" + name)
|
||||
|
||||
@@ -106,12 +106,18 @@ func get_sent_state_props_ratio() -> float:
|
||||
func push_full_state(state: Dictionary) -> void:
|
||||
_full_state_props_accum += state.size()
|
||||
|
||||
func push_full_state_props(count: int) -> void:
|
||||
_full_state_props_accum += count
|
||||
|
||||
func push_full_state_broadcast(state: Dictionary) -> void:
|
||||
_full_state_props_accum += state.size() * (multiplayer.get_peers().size() - 1)
|
||||
|
||||
func push_sent_state(state: Dictionary) -> void:
|
||||
_sent_state_props_accum += state.size()
|
||||
|
||||
func push_sent_state_props(count: int) -> void:
|
||||
_sent_state_props_accum += count
|
||||
|
||||
func push_sent_state_broadcast(state: Dictionary) -> void:
|
||||
_sent_state_props_accum += state.size() * (multiplayer.get_peers().size() - 1)
|
||||
|
||||
|
||||
@@ -115,6 +115,11 @@ var _offset: float = 0.
|
||||
var _rtt: float = 0.
|
||||
var _rtt_jitter: float = 0.
|
||||
|
||||
@onready var _cmd_ping := NetworkCommandServer.register_command(_handle_ping, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
|
||||
@onready var _cmd_pong := NetworkCommandServer.register_command(_handle_pong, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
|
||||
@onready var _cmd_req_time := NetworkCommandServer.register_command(_handle_request_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
|
||||
@onready var _cmd_set_time := NetworkCommandServer.register_command(_handle_set_timestamp, MultiplayerPeer.TRANSFER_MODE_RELIABLE)
|
||||
|
||||
## Emitted after the initial time sync.
|
||||
##
|
||||
## At the start of the game, clients request an initial timestamp to kickstart
|
||||
@@ -145,7 +150,7 @@ func start() -> void:
|
||||
_sample_idx = 0
|
||||
_sample_buffer = _RingBuffer.new(sync_samples)
|
||||
|
||||
_request_timestamp.rpc_id(1)
|
||||
_cmd_req_time.send(PackedByteArray(), 1)
|
||||
|
||||
## Stop the time synchronization loop.
|
||||
func stop() -> void:
|
||||
@@ -169,7 +174,7 @@ func _loop() -> void:
|
||||
_awaiting_samples[_sample_idx] = sample
|
||||
|
||||
sample.ping_sent = _clock.get_time()
|
||||
_send_ping.rpc_id(1, _sample_idx)
|
||||
_cmd_ping.send(var_to_bytes(_sample_idx), 1)
|
||||
|
||||
_sample_idx += 1
|
||||
|
||||
@@ -235,15 +240,19 @@ func _discipline_clock() -> void:
|
||||
|
||||
_offset = offset - nudge
|
||||
|
||||
@rpc("any_peer", "call_remote", "unreliable")
|
||||
func _send_ping(idx: int) -> void:
|
||||
func _handle_ping(sender: int, data: PackedByteArray) -> void:
|
||||
var idx := bytes_to_var(data) as int
|
||||
var ping_received := _clock.get_time()
|
||||
var sender := multiplayer.get_remote_sender_id()
|
||||
|
||||
_send_pong.rpc_id(sender, idx, ping_received, _clock.get_time())
|
||||
_cmd_pong.send(var_to_bytes([idx, ping_received, _clock.get_time()]), sender)
|
||||
|
||||
func _handle_pong(sender: int, data: PackedByteArray) -> void:
|
||||
var args := bytes_to_var(data)
|
||||
|
||||
var idx := args[0] as int
|
||||
var ping_received := args[1] as float
|
||||
var pong_sent := args[2] as float
|
||||
|
||||
@rpc("any_peer", "call_remote", "unreliable")
|
||||
func _send_pong(idx: int, ping_received: float, pong_sent: float) -> void:
|
||||
var pong_received := _clock.get_time()
|
||||
|
||||
if not _awaiting_samples.has(idx):
|
||||
@@ -264,13 +273,13 @@ func _send_pong(idx: int, ping_received: float, pong_sent: float) -> void:
|
||||
# Discipline clock based on new sample
|
||||
_discipline_clock()
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _request_timestamp() -> void:
|
||||
func _handle_request_timestamp(sender: int, data: PackedByteArray) -> void:
|
||||
_logger.debug("Requested initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
|
||||
_set_timestamp.rpc_id(multiplayer.get_remote_sender_id(), _clock.get_time())
|
||||
_cmd_set_time.send(var_to_bytes(_clock.get_time()), sender)
|
||||
|
||||
func _handle_set_timestamp(sender: int, data: PackedByteArray) -> void:
|
||||
var timestamp := bytes_to_var(data) as float
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _set_timestamp(timestamp: float) -> void:
|
||||
_logger.debug("Received initial timestamp @ %.4fs raw time", [_clock.get_raw_time()])
|
||||
_clock.set_time(timestamp)
|
||||
_loop()
|
||||
|
||||
@@ -19,7 +19,7 @@ var tickrate: int:
|
||||
|
||||
## Whether to sync the network ticks to physics updates.
|
||||
##
|
||||
## When set to true, tickrate will be the same as the physics ticks per second,
|
||||
## When set to true, tickrate will be the same as the physics ticks per second,
|
||||
## and the network tick loop will be run inside the physics update process.
|
||||
##
|
||||
## [i]read-only[/i], you can change this in the project settings
|
||||
@@ -33,7 +33,7 @@ var sync_to_physics: bool:
|
||||
##
|
||||
## If the game itself runs slower than the configured tickrate, multiple ticks
|
||||
## will be run in a single go. However, to avoid an endless feedback loop of
|
||||
## running too many ticks in a frame, which makes the game even slower, which
|
||||
## running too many ticks in a frame, which makes the game even slower, which
|
||||
## results in even more ticks and so on, this setting is an upper limit on how
|
||||
## many ticks can be simulated in a single go.
|
||||
##
|
||||
@@ -51,7 +51,7 @@ var max_ticks_per_frame: int:
|
||||
##
|
||||
## Use this value in cases where timestamps need to be shared with the server.
|
||||
##
|
||||
## [i]Note:[/i] Time is continuously synced with the server. If the difference
|
||||
## [i]Note:[/i] Time is continuously synced with the server. If the difference
|
||||
## between local and server time is above a certain threshold, this value will
|
||||
## be adjusted.
|
||||
##
|
||||
@@ -72,7 +72,7 @@ var time: float:
|
||||
##
|
||||
## Use this value in cases where timestamps need to be shared with the server.
|
||||
##
|
||||
## [i]Note:[/i] Time is continuously synced with the server. If the difference
|
||||
## [i]Note:[/i] Time is continuously synced with the server. If the difference
|
||||
## between local and server time is above a certain threshold, this value will
|
||||
## be adjusted.
|
||||
##
|
||||
@@ -88,7 +88,7 @@ var tick: int:
|
||||
|
||||
## Threshold before recalibrating [member tick] and [member time].
|
||||
##
|
||||
## Time is continuously synced to the server. In case the time difference is
|
||||
## Time is continuously synced to the server. In case the time difference is
|
||||
## excessive between local and the server, both [code]tick[/code] and
|
||||
## [code]time[/code] will be reset to the estimated server values.
|
||||
## [br][br]
|
||||
@@ -119,7 +119,7 @@ var stall_threshold: float:
|
||||
|
||||
## Current network time in ticks on the server.
|
||||
##
|
||||
## This is value is only an estimate, and is regularly updated. This means that
|
||||
## This is value is only an estimate, and is regularly updated. This means that
|
||||
## this value can and probably will change depending on network conditions.
|
||||
## [br][br]
|
||||
## [i]read-only[/i]
|
||||
@@ -133,7 +133,7 @@ var remote_tick: int:
|
||||
|
||||
## Current network time in seconds on the server.
|
||||
##
|
||||
## This is value is only an estimate, and is regularly updated. This means that
|
||||
## This is value is only an estimate, and is regularly updated. This means that
|
||||
## this value can and probably will change depending on network conditions.
|
||||
## [br][br]
|
||||
## [i]read-only[/i]
|
||||
@@ -147,8 +147,8 @@ var remote_time: float:
|
||||
|
||||
## Estimated roundtrip time to server.
|
||||
##
|
||||
## This value is updated regularly, during server time sync. Latency can be
|
||||
## estimated as half of the roundtrip time. Returns the same as [member
|
||||
## This value is updated regularly, during server time sync. Latency can be
|
||||
## estimated as half of the roundtrip time. Returns the same as [member
|
||||
## _NetworkTimeSynchronizer.rtt].
|
||||
## [br][br]
|
||||
## Will always be 0 on servers.
|
||||
@@ -163,7 +163,7 @@ var remote_rtt: float:
|
||||
## Current network time in ticks.
|
||||
##
|
||||
## On clients, this value is synced to the server [i]only once[/i] when joining
|
||||
## the game. After that, it will increase monotonically, incrementing every
|
||||
## the game. After that, it will increase monotonically, incrementing every
|
||||
## single tick.
|
||||
## [br][br]
|
||||
## When hosting, this value is simply the number of ticks since game start.
|
||||
@@ -183,7 +183,7 @@ var local_tick: int:
|
||||
## Current network time in seconds.
|
||||
##
|
||||
## On clients, this value is synced to the server [i]only once[/i] when joining
|
||||
## the game. After that, it will increase monotonically, incrementing every
|
||||
## the game. After that, it will increase monotonically, incrementing every
|
||||
## single tick.
|
||||
## [br][br]
|
||||
## When hosting, this value is simply the seconds elapsed since game start.
|
||||
@@ -199,7 +199,7 @@ var local_time: float:
|
||||
return time
|
||||
set(v):
|
||||
push_error("Trying to set read-only variable local_time")
|
||||
|
||||
|
||||
|
||||
## Amount of time a single tick takes, in seconds.
|
||||
##
|
||||
@@ -231,13 +231,13 @@ var tick_factor: float:
|
||||
## Multiplier to get from physics process speeds to tick speeds.
|
||||
##
|
||||
## Some methods, like CharacterBody's move_and_slide take velocity in units/sec
|
||||
## and figure out the time delta on their own. However, they are not aware of
|
||||
## and figure out the time delta on their own. However, they are not aware of
|
||||
## netfox's time, so motion is all wrong in a network tick. For example, the
|
||||
## network ticks run at 30 fps, while the game is running at 60fps, thus
|
||||
## network ticks run at 30 fps, while the game is running at 60fps, thus
|
||||
## move_and_slide will also assume that it's running on 60fps, resulting in
|
||||
## slower than expected movement.
|
||||
##
|
||||
## To circument this, you can multiply any velocities with this variable, and
|
||||
## To circument this, you can multiply any velocities with this variable, and
|
||||
## get the desired speed. Don't forget to then divide by this value if it's a
|
||||
## persistent variable ( e.g. CharacterBody's velocity ).
|
||||
##
|
||||
@@ -256,9 +256,9 @@ var physics_factor: float:
|
||||
|
||||
## The maximum clock stretch factor allowed.
|
||||
##
|
||||
## For more context on clock stretch, see [member clock_stretch_factor]. The
|
||||
## minimum allowed clock stretch factor is derived as 1.0 / clock_stretch_max.
|
||||
## Setting this to larger values will allow for quicker clock adjustment at the
|
||||
## For more context on clock stretch, see [member clock_stretch_factor]. The
|
||||
## minimum allowed clock stretch factor is derived as 1.0 / clock_stretch_max.
|
||||
## Setting this to larger values will allow for quicker clock adjustment at the
|
||||
## cost of bigger deviations in game speed.
|
||||
## [br][br]
|
||||
## Make sure to adjust this value based on the game's needs.
|
||||
@@ -286,7 +286,7 @@ var suppress_offline_peer_warning: bool:
|
||||
## to catch up. When the remote clock is ahead of the simulation clock, the game
|
||||
## will run slightly faster to catch up with the remote clock.
|
||||
## [br][br]
|
||||
## This value indicates the current clock speed multiplier. Values over 1.0
|
||||
## This value indicates the current clock speed multiplier. Values over 1.0
|
||||
## indicate speeding up, under 1.0 indicate slowing down.
|
||||
## [br][br]
|
||||
## See [member clock_stretch_max] for clock stretch bounds.[br]
|
||||
@@ -300,7 +300,7 @@ var clock_stretch_factor: float:
|
||||
|
||||
## The current estimated offset between the reference clock and the simulation
|
||||
## clock.
|
||||
##
|
||||
##
|
||||
## Positive values mean the simulation clock is behind, and needs to run
|
||||
## slightly faster to catch up. Negative values mean the simulation clock is
|
||||
## ahead, and needs to slow down slightly.
|
||||
@@ -316,7 +316,7 @@ var clock_offset: float:
|
||||
## The current estimated offset between the reference clock and the remote
|
||||
## clock.
|
||||
##
|
||||
## Positive values mean the reference clock is behind the remote clock.
|
||||
## Positive values mean the reference clock is behind the remote clock.
|
||||
## Negative values mean the reference clock is ahead of the remote clock.
|
||||
## [br][br]
|
||||
## Returns the same as [member _NetworkTimeSynchronizer.remote_offset].
|
||||
@@ -426,21 +426,21 @@ func start() -> int:
|
||||
# Reset state
|
||||
_tick = 0
|
||||
_initial_sync_done = false
|
||||
|
||||
|
||||
# Host is always synced, as their time is considered ground truth
|
||||
_synced_peers[1] = true
|
||||
|
||||
# Start sync
|
||||
NetworkTimeSynchronizer.start()
|
||||
_state = _STATE_SYNCING
|
||||
|
||||
|
||||
if not multiplayer.is_server():
|
||||
await NetworkTimeSynchronizer.on_initial_sync
|
||||
|
||||
_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time())
|
||||
_initial_sync_done = true
|
||||
_state = _STATE_ACTIVE
|
||||
|
||||
|
||||
_submit_sync_success.rpc()
|
||||
else:
|
||||
_state = _STATE_ACTIVE
|
||||
@@ -462,13 +462,17 @@ func start() -> int:
|
||||
|
||||
## Stop NetworkTime.
|
||||
##
|
||||
## This will stop the time sync in the background, and no more ticks will be
|
||||
## This will stop the time sync in the background, and no more ticks will be
|
||||
## emitted until the next start.
|
||||
func stop() -> void:
|
||||
NetworkTimeSynchronizer.stop()
|
||||
_tickrate_handshake.stop()
|
||||
|
||||
# Reset state
|
||||
_state = _STATE_INACTIVE
|
||||
_synced_peers.clear()
|
||||
_tick = 0
|
||||
_initial_sync_done = false
|
||||
|
||||
if multiplayer.peer_disconnected.is_connected(_handle_peer_disconnect):
|
||||
multiplayer.peer_disconnected.disconnect(_handle_peer_disconnect)
|
||||
@@ -508,7 +512,7 @@ func _ready() -> void:
|
||||
|
||||
_tickrate_handshake = NetworkTickrateHandshake.new()
|
||||
add_child(_tickrate_handshake)
|
||||
|
||||
|
||||
# Proxy tickrate mismatch event
|
||||
_tickrate_handshake.on_tickrate_mismatch.connect(func(peer, tickrate):
|
||||
on_tickrate_mismatch.emit(peer, tickrate)
|
||||
@@ -524,10 +528,10 @@ func _loop() -> void:
|
||||
# Adjust local clock
|
||||
_clock.step(_clock_stretch_factor)
|
||||
var clock_diff := NetworkTimeSynchronizer.get_time() - _clock.get_time()
|
||||
|
||||
|
||||
# Ignore diffs under 1ms
|
||||
clock_diff = sign(clock_diff) * max(abs(clock_diff) - 0.001, 0.)
|
||||
|
||||
|
||||
var clock_stretch_min := 1. / clock_stretch_max
|
||||
# var clock_stretch_f = (1. + clock_diff / (1. * ticktime)) / 2.
|
||||
var clock_stretch_f := inverse_lerp(-ticktime, +ticktime, clock_diff)
|
||||
@@ -535,7 +539,7 @@ func _loop() -> void:
|
||||
|
||||
var previous_stretch_factor := _clock_stretch_factor
|
||||
_clock_stretch_factor = lerpf(clock_stretch_min, clock_stretch_max, clock_stretch_f)
|
||||
|
||||
|
||||
# Detect editor pause
|
||||
var clock_step := _clock.get_time() - _last_process_time
|
||||
var clock_step_raw := clock_step / previous_stretch_factor
|
||||
@@ -550,7 +554,7 @@ func _loop() -> void:
|
||||
_was_paused = false
|
||||
_next_tick_time += clock_step
|
||||
_tick = seconds_to_ticks(NetworkTimeSynchronizer.get_time())
|
||||
|
||||
|
||||
# Run tick loop if needed
|
||||
var ticks_in_loop := 0
|
||||
_last_process_time = _clock.get_time()
|
||||
@@ -559,19 +563,24 @@ func _loop() -> void:
|
||||
before_tick_loop.emit()
|
||||
|
||||
before_tick.emit(ticktime, tick)
|
||||
|
||||
on_tick.emit(ticktime, tick)
|
||||
|
||||
after_tick.emit(ticktime, tick)
|
||||
|
||||
NetworkHistoryServer._record_sync_state(tick + 1)
|
||||
NetworkSynchronizationServer._synchronize_sync_state(tick + 1)
|
||||
|
||||
_tick += 1
|
||||
ticks_in_loop += 1
|
||||
_next_tick_time += ticktime
|
||||
|
||||
|
||||
if ticks_in_loop > 0:
|
||||
after_tick_loop.emit()
|
||||
NetworkHistoryServer._restore_synchronizer_state(tick)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
_process_delta = delta
|
||||
|
||||
|
||||
if _is_active() and not sync_to_physics:
|
||||
_loop()
|
||||
|
||||
@@ -592,9 +601,9 @@ func _handle_peer_disconnect(peer: int) -> void:
|
||||
@rpc("any_peer", "reliable", "call_local")
|
||||
func _submit_sync_success() -> void:
|
||||
var peer_id := multiplayer.get_remote_sender_id()
|
||||
|
||||
|
||||
_logger.trace("Received time sync success from #%s, synced peers: %s", [peer_id, _synced_peers.keys()])
|
||||
|
||||
|
||||
if not _synced_peers.has(peer_id):
|
||||
_synced_peers[peer_id] = true
|
||||
after_client_sync.emit(peer_id)
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
name="netfox"
|
||||
description="Shared internals for netfox addons"
|
||||
author="Tamas Galffy and contributors"
|
||||
version="1.35.3"
|
||||
version="1.40.2"
|
||||
script="netfox.gd"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
extends _HistoryBuffer
|
||||
class_name _PropertyHistoryBuffer
|
||||
|
||||
func get_snapshot(tick: int) -> _PropertySnapshot:
|
||||
if _buffer.has(tick):
|
||||
return _buffer[tick]
|
||||
else:
|
||||
return _PropertySnapshot.new()
|
||||
|
||||
func set_snapshot(tick: int, data) -> void:
|
||||
if data is Dictionary:
|
||||
var snapshot := _PropertySnapshot.from_dictionary(data)
|
||||
super(tick, snapshot)
|
||||
elif data is _PropertySnapshot:
|
||||
super(tick, data)
|
||||
else:
|
||||
push_error("Data not a PropertSnapshot! %s" % [data])
|
||||
|
||||
func get_history(tick: int) -> _PropertySnapshot:
|
||||
var snapshot = super(tick)
|
||||
|
||||
return snapshot if snapshot else _PropertySnapshot.new()
|
||||
|
||||
func trim(earliest_tick_to_keep: int = NetworkRollback.history_start) -> void:
|
||||
super(earliest_tick_to_keep)
|
||||
|
||||
func merge(data: _PropertySnapshot, tick:int) -> void:
|
||||
set_snapshot(tick, get_snapshot(tick).merge(data))
|
||||
@@ -1,111 +0,0 @@
|
||||
extends RefCounted
|
||||
class_name _RollbackHistoryRecorder
|
||||
|
||||
# Provided externally by RBS
|
||||
var _state_history: _PropertyHistoryBuffer
|
||||
var _input_history: _PropertyHistoryBuffer
|
||||
|
||||
var _state_property_config: _PropertyConfig
|
||||
var _input_property_config: _PropertyConfig
|
||||
|
||||
var _property_cache: PropertyCache
|
||||
|
||||
var _latest_state_tick: int
|
||||
var _skipset: _Set
|
||||
|
||||
func configure(
|
||||
p_state_history: _PropertyHistoryBuffer, p_input_history: _PropertyHistoryBuffer,
|
||||
p_state_property_config: _PropertyConfig, p_input_property_config: _PropertyConfig,
|
||||
p_property_cache: PropertyCache,
|
||||
p_skipset: _Set
|
||||
) -> void:
|
||||
_state_history = p_state_history
|
||||
_input_history = p_input_history
|
||||
_state_property_config = p_state_property_config
|
||||
_input_property_config = p_input_property_config
|
||||
_property_cache = p_property_cache
|
||||
_skipset = p_skipset
|
||||
|
||||
func set_latest_state_tick(p_latest_state_tick: int) -> void:
|
||||
_latest_state_tick = p_latest_state_tick
|
||||
|
||||
func apply_state(tick: int) -> void:
|
||||
# Apply state for tick
|
||||
var state = _state_history.get_history(tick)
|
||||
state.apply(_property_cache)
|
||||
|
||||
func apply_display_state() -> void:
|
||||
apply_state(NetworkRollback.display_tick)
|
||||
|
||||
func apply_tick(tick: int) -> void:
|
||||
var state := _state_history.get_history(tick)
|
||||
var input := _input_history.get_history(tick)
|
||||
|
||||
state.apply(_property_cache)
|
||||
input.apply(_property_cache)
|
||||
|
||||
func trim_history() -> void:
|
||||
# Trim history
|
||||
_state_history.trim()
|
||||
_input_history.trim()
|
||||
|
||||
func record_input(tick: int) -> void:
|
||||
# Record input
|
||||
if not _get_recorded_input_props().is_empty():
|
||||
var input = _PropertySnapshot.extract(_get_recorded_input_props())
|
||||
var input_tick: int = tick + NetworkRollback.input_delay
|
||||
_input_history.set_snapshot(input_tick, input)
|
||||
|
||||
func record_state(tick: int) -> void:
|
||||
# Record state for specified tick ( current + 1 )
|
||||
# Check if any of the managed nodes were mutated
|
||||
var is_mutated := _get_recorded_state_props().any(func(pe):
|
||||
return NetworkRollback.is_mutated(pe.node, tick - 1))
|
||||
|
||||
var record_state := _PropertySnapshot.extract(_get_state_props_to_record(tick))
|
||||
if record_state.size():
|
||||
var merge_state := _state_history.get_history(tick - 1)
|
||||
_state_history.set_snapshot(tick, merge_state.merge(record_state))
|
||||
|
||||
func _should_record_tick(tick: int) -> bool:
|
||||
if _get_recorded_state_props().is_empty():
|
||||
# Don't record tick if there's no props to record
|
||||
return false
|
||||
|
||||
if _get_recorded_state_props().any(func(pe):
|
||||
return NetworkRollback.is_mutated(pe.node, tick - 1)):
|
||||
# If there's any node that was mutated, there's something to record
|
||||
return true
|
||||
|
||||
# Otherwise, record only if we don't have authoritative state for the tick
|
||||
return tick > _latest_state_tick
|
||||
|
||||
func _get_state_props_to_record(tick: int) -> Array[PropertyEntry]:
|
||||
if not _should_record_tick(tick):
|
||||
return []
|
||||
if _skipset.is_empty():
|
||||
return _get_recorded_state_props()
|
||||
|
||||
return _get_recorded_state_props().filter(func(pe): return _should_record_property(pe, tick))
|
||||
|
||||
func _should_record_property(property_entry: PropertyEntry, tick: int) -> bool:
|
||||
if NetworkRollback.is_mutated(property_entry.node, tick - 1):
|
||||
return true
|
||||
if _skipset.has(property_entry.node):
|
||||
return false
|
||||
return true
|
||||
|
||||
# =============================================================================
|
||||
# Shared utils, extract later
|
||||
|
||||
func _get_recorded_state_props() -> Array[PropertyEntry]:
|
||||
return _state_property_config.get_properties()
|
||||
|
||||
func _get_owned_state_props() -> Array[PropertyEntry]:
|
||||
return _state_property_config.get_owned_properties()
|
||||
|
||||
func _get_recorded_input_props() -> Array[PropertyEntry]:
|
||||
return _input_property_config.get_owned_properties()
|
||||
|
||||
func _get_owned_input_props() -> Array[PropertyEntry]:
|
||||
return _input_property_config.get_owned_properties()
|
||||
@@ -1 +0,0 @@
|
||||
uid://bn6fsqxbfhihk
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user