Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597d6dde2d | |||
| aad186552c | |||
| 5a1695e2ab | |||
| fc2c0236cb | |||
| ba2fc37502 | |||
| e24637b049 | |||
| 8c790357d3 | |||
| 00bb8a21d2 | |||
| 70105d8b68 | |||
| 256f0d9a74 | |||
| 4a834a1d55 | |||
| 841ce19740 | |||
| 5b93c514c7 | |||
| b0c83af092 | |||
| ce39b237c3 | |||
| a69c60ce85 | |||
| 969741aa31 | |||
| e70ce76207 | |||
| 926446e5cf | |||
| e7299b17e9 | |||
| e2dc429caa | |||
| 25221bf3cb |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,18 @@
|
||||
Copyright 2023 Gálffy Tamás
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,34 @@
|
||||
# netfox.extras
|
||||
|
||||
High-level, game-specific extras for [netfox]!
|
||||
|
||||
## Features
|
||||
|
||||
* 🔫 Networked weapons
|
||||
* ⌨️ Rollback-aware base class for input
|
||||
|
||||
## Overview
|
||||
|
||||
This addon is pretty much a catch-all project for things that are useful, but
|
||||
are not core to netfox.
|
||||
|
||||
## Install
|
||||
|
||||
See the root [README](../../README.md).
|
||||
|
||||
## Usage
|
||||
|
||||
See the [docs](https://foxssake.github.io/netfox/).
|
||||
|
||||
## License
|
||||
|
||||
netfox.extras is under the [MIT license](LICENSE).
|
||||
|
||||
## Issues
|
||||
|
||||
In case of any issues, comments, or questions, please feel free to [open an issue]!
|
||||
|
||||
[netfox]: https://github.com/foxssake/netfox
|
||||
[source]: https://github.com/foxssake/netfox/archive/refs/heads/main.zip
|
||||
[open an issue]: https://github.com/foxssake/netfox/issues
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
extends Node
|
||||
class_name BaseNetInput
|
||||
|
||||
## Base class for Input nodes used with rollback.
|
||||
|
||||
func _ready():
|
||||
NetworkTime.before_tick_loop.connect(func():
|
||||
if is_multiplayer_authority():
|
||||
_gather()
|
||||
)
|
||||
|
||||
## Method for gathering input.
|
||||
##
|
||||
## This method is supposed to be overridden with your input logic. The input
|
||||
## data itself may be gathered outside of this method ( e.g. gathering it over
|
||||
## multiple _process calls ), but this is the point where the input variables
|
||||
## must be set.
|
||||
##
|
||||
## [i]Note:[/i] This is only called for the local player's input nodes.
|
||||
func _gather():
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpiuf65vuu0oa
|
||||
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="network-rigid-body-2d.svg"
|
||||
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" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="22.627417"
|
||||
inkscape:cx="-6.098796"
|
||||
inkscape:cy="12.705825"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1043"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1"
|
||||
showgrid="true">
|
||||
<inkscape:grid
|
||||
id="grid1"
|
||||
units="px"
|
||||
originx="0"
|
||||
originy="0"
|
||||
spacingx="1"
|
||||
spacingy="1"
|
||||
empcolor="#0099e5"
|
||||
empopacity="0.30196078"
|
||||
color="#0099e5"
|
||||
opacity="0.14901961"
|
||||
empspacing="5"
|
||||
enabled="true"
|
||||
visible="true" />
|
||||
</sodipodi:namedview>
|
||||
<path
|
||||
fill="#8da5f3"
|
||||
d="M 8,1 A 7,7 0 0 0 8,15 7,7 0 0 0 8,1 Z M 9.951,2.334 A 6,6 0 0 1 14.001,8 h -7 A 2,2 0 0 0 6.051,6.3 C 7.397,4.271 8.655,3.037 9.951,2.333 Z m -6.828,2.18 c 0.146,0.65 0.358,1.283 0.684,1.884 A 2,2 0 0 0 3,8 H 2 A 6,6 0 0 1 3.123,4.514 Z M 3.8,9.6 a 2,2 0 0 0 2.4,0 c 1.472,2.027 2.728,3.264 3.8,4 A 6,6 0 0 1 2.85,11.1 c 0.25,-0.6 0.5,-1 0.95,-1.5 z"
|
||||
id="path1"
|
||||
style="stroke-width:1" />
|
||||
<g
|
||||
id="g9"
|
||||
transform="matrix(0.25,0,0,0.25,0,0.825)">
|
||||
<path
|
||||
id="path1_00000101101429107098172700000017609064003007162547_"
|
||||
class="st1"
|
||||
d="M 32,40.2 23.8,56.6 15.6,40.2 Z"
|
||||
style="fill:#5fff97" />
|
||||
<path
|
||||
id="path2_00000126280959979031010980000006949781421479937947_"
|
||||
class="st2"
|
||||
d="M 48.4,56.7 40.2,40.3 32,56.7 Z"
|
||||
style="fill:#ff5f5f" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="network-rigid-body-3d.svg"
|
||||
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" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="21.364726"
|
||||
inkscape:cx="21.109561"
|
||||
inkscape:cy="8.776148"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1043"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<path
|
||||
fill="#fc7f7f"
|
||||
d="M 8,1 A 7,7 0 0 0 8,15 7,7 0 0 0 8,1 Z M 9.951,2.334 A 6,6 0 0 1 14.001,8 h -7 A 2,2 0 0 0 6.051,6.3 C 7.397,4.271 8.655,3.037 9.951,2.333 Z m -6.828,2.18 c 0.146,0.65 0.358,1.283 0.684,1.884 A 2,2 0 0 0 3,8 H 2 A 6,6 0 0 1 3.123,4.514 Z M 3.8,9.6 a 2,2 0 0 0 2.4,0 c 1.472,2.027 2.728,3.264 3.8,4 A 6,6 0 0 1 2.85,11.1 c 0.25,-0.6 0.5,-1 0.95,-1.5 z"
|
||||
id="path1"
|
||||
style="stroke-width:1" />
|
||||
<g
|
||||
id="g9"
|
||||
transform="matrix(0.25,0,0,0.25,0,0.825)">
|
||||
<path
|
||||
id="path1_00000101101429107098172700000017609064003007162547_"
|
||||
class="st1"
|
||||
d="M 32,40.2 23.8,56.6 15.6,40.2 Z"
|
||||
style="fill:#5fff97" />
|
||||
<path
|
||||
id="path2_00000126280959979031010980000006949781421479937947_"
|
||||
class="st2"
|
||||
d="M 48.4,56.7 40.2,40.3 32,56.7 Z"
|
||||
style="fill:#ff5f5f" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -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,91 @@
|
||||
<?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="rewindable-state-machine.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
width="16"
|
||||
height="16"
|
||||
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:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : -47.5 : 1"
|
||||
inkscape:vp_y="0 : 15.625 : 0"
|
||||
inkscape:vp_z="1 : -47.5 : 1"
|
||||
inkscape:persp3d-origin="0.5 : -47.666667 : 1"
|
||||
id="perspective1" /></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"
|
||||
inkscape:zoom="24.411443"
|
||||
inkscape:cx="2.5397925"
|
||||
inkscape:cy="9.4832576"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1043"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_1" />
|
||||
<style
|
||||
type="text/css"
|
||||
id="style1">
|
||||
.st0{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;}
|
||||
.st1{fill:#5FFF97;}
|
||||
.st2{fill:#FF5F5F;}
|
||||
.st3{opacity:0.8;}
|
||||
</style>
|
||||
|
||||
<path
|
||||
sodipodi:type="star"
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:10.42235363;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path1"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8.0625"
|
||||
sodipodi:cy="8.0625"
|
||||
sodipodi:r1="27.830996"
|
||||
sodipodi:r2="24.102348"
|
||||
sodipodi:arg1="0.53701091"
|
||||
sodipodi:arg2="1.0606097"
|
||||
inkscape:rounded="-9.1940344e-17"
|
||||
inkscape:randomized="0"
|
||||
d="M 31.97605,22.3 7.6892381,35.890992 -16.224312,21.653492 -15.85105,-6.1750005 8.4357619,-19.765992 32.349312,-5.5284919 Z"
|
||||
transform="matrix(0.19189523,0,0,0.19189524,6.4528447,6.4528446)" />
|
||||
|
||||
|
||||
<circle
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path9"
|
||||
cx="7.9999995"
|
||||
cy="7.8039598"
|
||||
r="2.5" /><circle
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.66"
|
||||
id="circle9"
|
||||
cx="6.9999995"
|
||||
cy="7.8039598"
|
||||
r="2.5" /><circle
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:1;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.33"
|
||||
id="circle10"
|
||||
cx="5.9999995"
|
||||
cy="7.8039598"
|
||||
r="2.5" /><g
|
||||
id="g9"
|
||||
transform="matrix(0.25,0,0,0.25,0,-0.19604137)"><path
|
||||
id="path1_00000101101429107098172700000017609064003007162547_"
|
||||
class="st1"
|
||||
d="M 32,40.2 23.8,56.6 15.6,40.2 Z" /><path
|
||||
id="path2_00000126280959979031010980000006949781421479937947_"
|
||||
class="st2"
|
||||
d="M 48.4,56.7 40.2,40.3 32,56.7 Z" /></g></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -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,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="rewindable-state.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
width="16"
|
||||
height="16"
|
||||
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:perspective
|
||||
sodipodi:type="inkscape:persp3d"
|
||||
inkscape:vp_x="0 : -47.5 : 1"
|
||||
inkscape:vp_y="0 : 15.625 : 0"
|
||||
inkscape:vp_z="1 : -47.5 : 1"
|
||||
inkscape:persp3d-origin="0.5 : -47.666667 : 1"
|
||||
id="perspective1" /></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"
|
||||
inkscape:zoom="32"
|
||||
inkscape:cx="6.28125"
|
||||
inkscape:cy="8.328125"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1043"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="Layer_1" />
|
||||
<style
|
||||
type="text/css"
|
||||
id="style1">
|
||||
.st0{fill:none;stroke:#E0E0E0;stroke-width:6.875;stroke-linecap:round;stroke-linejoin:round;}
|
||||
.st1{fill:#5FFF97;}
|
||||
.st2{fill:#FF5F5F;}
|
||||
.st3{opacity:0.8;}
|
||||
</style>
|
||||
|
||||
<g
|
||||
id="g8"
|
||||
transform="matrix(0.25,0,0,0.25,10.94454,-0.19604083)"
|
||||
style="stroke:#e0e0e0;stroke-opacity:0.33;stroke-width:8;stroke-dasharray:none"><path
|
||||
sodipodi:type="star"
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:10.42235363;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.33"
|
||||
id="path7"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8.0625"
|
||||
sodipodi:cy="8.0625"
|
||||
sodipodi:r1="27.830996"
|
||||
sodipodi:r2="24.102348"
|
||||
sodipodi:arg1="0.53701091"
|
||||
sodipodi:arg2="1.0606097"
|
||||
inkscape:rounded="-9.1940344e-17"
|
||||
inkscape:randomized="0"
|
||||
d="M 31.97605,22.3 7.6892381,35.890992 -16.224312,21.653492 -15.85105,-6.1750005 8.4357619,-19.765992 32.349312,-5.5284919 Z"
|
||||
transform="matrix(0.76758094,0,0,0.76758094,-21.96678,26.595542)" /><path
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:8;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.33"
|
||||
d="m -34.133744,21.855729 18.355585,10.928434 -0.286508,21.360621"
|
||||
id="path8" /></g><g
|
||||
id="g6"
|
||||
transform="matrix(0.25,0,0,0.25,11.94454,-0.19604083)"
|
||||
style="stroke:#e0e0e0;stroke-opacity:0.66;stroke-width:8;stroke-dasharray:none"><path
|
||||
sodipodi:type="star"
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:10.42235363;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.66"
|
||||
id="path5"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8.0625"
|
||||
sodipodi:cy="8.0625"
|
||||
sodipodi:r1="27.830996"
|
||||
sodipodi:r2="24.102348"
|
||||
sodipodi:arg1="0.53701091"
|
||||
sodipodi:arg2="1.0606097"
|
||||
inkscape:rounded="-9.1940344e-17"
|
||||
inkscape:randomized="0"
|
||||
d="M 31.97605,22.3 7.6892381,35.890992 -16.224312,21.653492 -15.85105,-6.1750005 8.4357619,-19.765992 32.349312,-5.5284919 Z"
|
||||
transform="matrix(0.76758094,0,0,0.76758094,-21.96678,26.595542)" /><path
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:8;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:0.66"
|
||||
d="m -34.133744,21.855729 18.355585,10.928434 -0.286508,21.360621"
|
||||
id="path6" /></g><g
|
||||
id="g4"
|
||||
transform="matrix(0.25,0,0,0.25,12.94454,-0.19604083)"
|
||||
style="stroke-width:8;stroke-dasharray:none"><path
|
||||
sodipodi:type="star"
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:10.42235363;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="path1"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8.0625"
|
||||
sodipodi:cy="8.0625"
|
||||
sodipodi:r1="27.830996"
|
||||
sodipodi:r2="24.102348"
|
||||
sodipodi:arg1="0.53701091"
|
||||
sodipodi:arg2="1.0606097"
|
||||
inkscape:rounded="-9.1940344e-17"
|
||||
inkscape:randomized="0"
|
||||
d="M 31.97605,22.3 7.6892381,35.890992 -16.224312,21.653492 -15.85105,-6.1750005 8.4357619,-19.765992 32.349312,-5.5284919 Z"
|
||||
transform="matrix(0.76758094,0,0,0.76758094,-21.96678,26.595542)" /><path
|
||||
style="fill:none;stroke:#e0e0e0;stroke-width:8;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m -34.133744,21.855729 18.355585,10.928434 -0.286508,21.360621"
|
||||
id="path4" /></g><path
|
||||
id="path1_00000101101429107098172700000017609064003007162547_"
|
||||
class="st1"
|
||||
d="m 7.9999998,9.8539592 -2.05,4.0999998 -2.0499999,-4.0999998 z"
|
||||
style="stroke-width:2;stroke-dasharray:none" />
|
||||
<path
|
||||
id="path2_00000126280959979031010980000006949781421479937947_"
|
||||
class="st2"
|
||||
d="M 12.1,13.978959 10.05,9.8789592 7.9999998,13.978959 Z"
|
||||
style="stroke-width:2;stroke-dasharray:none" />
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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
|
||||
@@ -0,0 +1,192 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
const ROOT = "res://addons/netfox.extras"
|
||||
|
||||
var SETTINGS = [
|
||||
NetfoxLogger._make_setting("netfox/logging/netfox_extras_log_level"),
|
||||
|
||||
# Window Tiler Settings
|
||||
{
|
||||
"name": "netfox/extras/auto_tile_windows",
|
||||
"value": false,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
{
|
||||
"name": "netfox/extras/screen",
|
||||
"value": 0,
|
||||
"type": TYPE_INT
|
||||
},
|
||||
{
|
||||
"name": "netfox/extras/borderless",
|
||||
"value": false,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
|
||||
# Autoconnect settings
|
||||
{
|
||||
"name": "netfox/autoconnect/enabled",
|
||||
"value": false,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
{
|
||||
"name": "netfox/autoconnect/host",
|
||||
"value": "127.0.0.1",
|
||||
"type": TYPE_STRING
|
||||
},
|
||||
{
|
||||
"name": "netfox/autoconnect/port",
|
||||
"value": 9999,
|
||||
"type": TYPE_INT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "1,65535,hide_slider"
|
||||
},
|
||||
{
|
||||
"name": "netfox/autoconnect/use_compression",
|
||||
"value": false,
|
||||
"type": TYPE_BOOL
|
||||
},
|
||||
{
|
||||
"name": "netfox/autoconnect/simulated_latency_ms",
|
||||
"value": 0.0,
|
||||
"type": TYPE_INT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "0,200,or_greater"
|
||||
},
|
||||
{
|
||||
"name": "netfox/autoconnect/simulated_packet_loss_chance",
|
||||
"value": 0.0,
|
||||
"type": TYPE_FLOAT,
|
||||
"hint": PROPERTY_HINT_RANGE,
|
||||
"hint_string": "0,1"
|
||||
}
|
||||
]
|
||||
|
||||
const AUTOLOADS = [
|
||||
{
|
||||
"name": "WindowTiler",
|
||||
"path": ROOT + "/window-tiler.gd"
|
||||
},
|
||||
{
|
||||
"name": "NetworkSimulator",
|
||||
"path": ROOT + "/network-simulator.gd"
|
||||
}
|
||||
]
|
||||
|
||||
const TYPES: Array[Dictionary] = [
|
||||
{
|
||||
"name": "RewindableStateMachine",
|
||||
"base": "Node",
|
||||
"script": ROOT + "/state-machine/rewindable-state-machine.gd",
|
||||
"icon": ROOT + "/icons/rewindable-state-machine.svg"
|
||||
},
|
||||
{
|
||||
"name": "RewindableState",
|
||||
"base": "Node",
|
||||
"script": ROOT + "/state-machine/rewindable-state.gd",
|
||||
"icon": ROOT + "/icons/rewindable-state.svg"
|
||||
},
|
||||
{
|
||||
"name": "NetworkRigidBody2D",
|
||||
"base": "RigidBody2D",
|
||||
"script": ROOT + "/physics/network-rigid-body-2d.gd",
|
||||
"icon": ROOT + "/icons/network-rigid-body-2d.svg"
|
||||
},
|
||||
{
|
||||
"name": "NetworkRigidBody3D",
|
||||
"base": "RigidBody3D",
|
||||
"script": ROOT + "/physics/network-rigid-body-3d.gd",
|
||||
"icon": ROOT + "/icons/network-rigid-body-3d.svg"
|
||||
},
|
||||
]
|
||||
|
||||
const PhysicsDriverToggles := preload("res://addons/netfox.extras/physics/physics-driver-toggles.gd")
|
||||
|
||||
var _tool_menu_items := [] as Array[String]
|
||||
|
||||
func _enter_tree():
|
||||
for setting in SETTINGS:
|
||||
add_setting(setting)
|
||||
|
||||
for autoload in AUTOLOADS:
|
||||
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))
|
||||
|
||||
_render_tool_menu()
|
||||
|
||||
func _exit_tree():
|
||||
if ProjectSettings.get_setting("netfox/general/clear_settings", false):
|
||||
for setting in SETTINGS:
|
||||
remove_setting(setting)
|
||||
|
||||
for autoload in AUTOLOADS:
|
||||
if has_autoload(autoload.name):
|
||||
remove_autoload_singleton(autoload.name)
|
||||
|
||||
for type in TYPES:
|
||||
remove_custom_type(type.name)
|
||||
|
||||
_free_tool_menu()
|
||||
|
||||
func add_setting(setting: Dictionary):
|
||||
if ProjectSettings.has_setting(setting.name):
|
||||
return
|
||||
|
||||
ProjectSettings.set_setting(setting.name, setting.value)
|
||||
ProjectSettings.set_initial_value(setting.name, setting.value)
|
||||
ProjectSettings.add_property_info({
|
||||
"name": setting.get("name"),
|
||||
"type": setting.get("type"),
|
||||
"hint": setting.get("hint", PROPERTY_HINT_NONE),
|
||||
"hint_string": setting.get("hint_string", "")
|
||||
})
|
||||
|
||||
func remove_setting(setting: Dictionary):
|
||||
if not ProjectSettings.has_setting(setting.name):
|
||||
return
|
||||
|
||||
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():
|
||||
var prefix := "Enable" if not driver_toggle.is_enabled() else "Disable"
|
||||
var item := "%s %s physics driver" % [prefix, driver_toggle.get_name()]
|
||||
|
||||
_tool_menu_items.append(item)
|
||||
|
||||
add_tool_menu_item(item, func():
|
||||
# Toggle physics driver, then re-render menu to show changes
|
||||
_call_physics_driver_toggle(driver_toggle)
|
||||
_render_tool_menu()
|
||||
)
|
||||
|
||||
func _free_tool_menu():
|
||||
for item in _tool_menu_items:
|
||||
remove_tool_menu_item(item)
|
||||
_tool_menu_items.clear()
|
||||
|
||||
func _call_physics_driver_toggle(driver_toggle: PhysicsDriverToggles.PhysicsDriverToggle):
|
||||
var error_messages := driver_toggle.toggle()
|
||||
if not error_messages.is_empty():
|
||||
var error_text := "\n".join(error_messages)
|
||||
|
||||
var dialog := AcceptDialog.new()
|
||||
dialog.title = "Physics driver toggle failed!"
|
||||
dialog.dialog_text = error_text
|
||||
|
||||
get_editor_interface().popup_dialog_centered(dialog)
|
||||
else:
|
||||
var dialog := AcceptDialog.new()
|
||||
dialog.title = "Physics driver toggle success!"
|
||||
dialog.dialog_text = ("%s physics driver was successfully toggled! " +
|
||||
"You might need to refresh your script or reload project.") %\
|
||||
[driver_toggle.get_name()]
|
||||
|
||||
get_editor_interface().popup_dialog_centered(dialog)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ee1cb3bj7opr
|
||||
@@ -0,0 +1,238 @@
|
||||
extends Node
|
||||
|
||||
## Network Simulator
|
||||
##
|
||||
## Auto connects launched instances and simulates network conditions like
|
||||
## latency and packet loss. To use simply add this node to your scene tree and
|
||||
## hook up the signals.
|
||||
|
||||
## Signal emitted on the instance that successfully started a server.
|
||||
## Can be used for custom initialization logic, e.g. automatic login during
|
||||
## testing.
|
||||
signal server_created
|
||||
|
||||
## Signal emitted on instances that successfully connected as a client.
|
||||
## Can be used for custom initialization logic, e.g. automatic login during
|
||||
## testing.
|
||||
signal client_connected
|
||||
|
||||
## Enable to automatically host and connect on start
|
||||
var enabled: bool = false
|
||||
## Server listening address. Use [code]*[/code] for all interfaces, or
|
||||
#3 [code]127.0.0.1[/code] for localhost.
|
||||
var hostname: String = "127.0.0.1"
|
||||
|
||||
## Server port to listen on, UDP proxy will use port + 1 if simulating latency
|
||||
## or packet loss
|
||||
var server_port: int = 9999
|
||||
|
||||
## Use ENet's built-in range encoding for compression
|
||||
var use_compression: bool = true
|
||||
|
||||
## Simulated latency in milliseconds. Total ping time will be double this value
|
||||
## (to and from)
|
||||
var latency_ms: int = 0
|
||||
## Simulated packet loss percentage
|
||||
var packet_loss_percent: float = 0.0
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_extras("NetworkSimulator")
|
||||
|
||||
var _enet_peer: ENetMultiplayerPeer = ENetMultiplayerPeer.new()
|
||||
|
||||
# UDP proxy
|
||||
var _proxy_thread: Thread
|
||||
var _proxy_loop_enabled := true
|
||||
var _udp_proxy_server: PacketPeerUDP
|
||||
var _udp_proxy_port: int
|
||||
var _rng_packet_loss: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||
|
||||
# Connection tracking
|
||||
var _client_peers: Dictionary = {} # port to PacketPeerUDP
|
||||
var _client_to_server_queue: Array[QueueEntry] = []
|
||||
var _server_to_client_queue: Array[QueueEntry] = []
|
||||
|
||||
class QueueEntry:
|
||||
var packet_data: PackedByteArray
|
||||
var queued_at: int
|
||||
var source_port: int # Which client port this came from
|
||||
|
||||
func _init(packet: PackedByteArray, timestamp: int, port: int) -> void:
|
||||
self.packet_data = packet
|
||||
self.queued_at = timestamp
|
||||
self.source_port = port
|
||||
|
||||
func _ready() -> void:
|
||||
# Check if enabled
|
||||
if not OS.has_feature("editor"):
|
||||
_logger.debug("Running outside editor, disabling")
|
||||
return
|
||||
|
||||
_load_project_settings()
|
||||
if not enabled:
|
||||
_logger.debug("Feature disabled")
|
||||
return
|
||||
|
||||
for env_var in ["CI", "NETFOX_CI", "NETFOX_NO_AUTOCONNECT"]:
|
||||
if OS.get_environment(env_var) != "":
|
||||
_logger.debug("Environment variable %s set, disabling", [env_var])
|
||||
return
|
||||
|
||||
await get_tree().process_frame
|
||||
_udp_proxy_port = server_port + 1
|
||||
|
||||
var status = _try_and_host()
|
||||
if status == Error.ERR_CANT_CREATE:
|
||||
_try_and_join()
|
||||
elif status != OK:
|
||||
_logger.error("Autoconnect failed with error - %s", [error_string(status)])
|
||||
|
||||
if use_compression:
|
||||
_enet_peer.host.compress(ENetConnection.COMPRESS_RANGE_CODER)
|
||||
|
||||
multiplayer.multiplayer_peer = _enet_peer
|
||||
|
||||
func _is_proxy_required() -> bool:
|
||||
return latency_ms > 0 or packet_loss_percent > 0.0
|
||||
|
||||
func _try_and_host() -> Error:
|
||||
var status = _enet_peer.create_server(server_port)
|
||||
if status == OK:
|
||||
if _is_proxy_required():
|
||||
_start_udp_proxy()
|
||||
server_created.emit()
|
||||
_logger.info("Server started on port %s", [server_port])
|
||||
return status
|
||||
|
||||
func _try_and_join() -> Error:
|
||||
var connect_port = server_port
|
||||
if _is_proxy_required():
|
||||
connect_port = _udp_proxy_port
|
||||
var status = _enet_peer.create_client(hostname, connect_port)
|
||||
if status == OK:
|
||||
client_connected.emit()
|
||||
_logger.info("Client connected to %s:%s", [hostname, connect_port])
|
||||
return status
|
||||
|
||||
# Starts a UDP proxy server to simulate network conditions
|
||||
# This will listen on _udp_proxy_port and forward packets to the server_port
|
||||
# Runs on its own thread to avoid blocking the main thread
|
||||
func _start_udp_proxy() -> void:
|
||||
_proxy_thread = Thread.new()
|
||||
_udp_proxy_server = PacketPeerUDP.new()
|
||||
|
||||
var bind_status = _udp_proxy_server.bind(_udp_proxy_port, hostname)
|
||||
if bind_status != OK:
|
||||
_logger.error("Failed to bind UDP proxy port: ", bind_status)
|
||||
return
|
||||
|
||||
_proxy_thread.start(_process_loop)
|
||||
|
||||
func _process_packets() -> void:
|
||||
var current_time: int = Time.get_ticks_msec()
|
||||
var send_threshold: int = current_time - latency_ms
|
||||
|
||||
_read_client_to_server_packets(current_time)
|
||||
_process_client_to_server_packets(send_threshold)
|
||||
|
||||
if not _client_peers.is_empty():
|
||||
_read_server_to_client_packets(current_time)
|
||||
_process_server_to_client_queue(send_threshold)
|
||||
|
||||
func _process_loop():
|
||||
while _proxy_loop_enabled:
|
||||
_process_packets()
|
||||
OS.delay_msec(1)
|
||||
|
||||
func _load_project_settings() -> void:
|
||||
enabled = ProjectSettings.get_setting(&"netfox/autoconnect/enabled", false)
|
||||
hostname = ProjectSettings.get_setting(&"netfox/autoconnect/host", "127.0.0.1")
|
||||
server_port = ProjectSettings.get_setting(&"netfox/autoconnect/port", 9999)
|
||||
use_compression = ProjectSettings.get_setting(&"netfox/autoconnect/use_compression", false)
|
||||
latency_ms = ProjectSettings.get_setting(&"netfox/autoconnect/simulated_latency_ms", 0)
|
||||
packet_loss_percent = ProjectSettings.get_setting(&"netfox/autoconnect/simulated_packet_loss_chance", 0.0)
|
||||
|
||||
func _is_data_available() -> bool:
|
||||
if _udp_proxy_server.get_available_packet_count() > 0:
|
||||
return true
|
||||
|
||||
if not _client_to_server_queue.is_empty() or not _server_to_client_queue.is_empty():
|
||||
return true
|
||||
|
||||
# Check if any client peers have packets waiting
|
||||
for client_peer in _client_peers.values():
|
||||
if client_peer.get_available_packet_count() > 0:
|
||||
return true
|
||||
|
||||
return false
|
||||
|
||||
func _read_client_to_server_packets(current_time: int) -> void:
|
||||
while _udp_proxy_server.get_available_packet_count() > 0:
|
||||
var packet = _udp_proxy_server.get_packet()
|
||||
var err = _udp_proxy_server.get_packet_error()
|
||||
|
||||
if err != OK:
|
||||
_logger.error("UDP proxy incoming packet error: ", err)
|
||||
continue
|
||||
|
||||
var from_port = _udp_proxy_server.get_packet_port()
|
||||
_register_client_if_new(from_port)
|
||||
|
||||
_client_to_server_queue.push_back(QueueEntry.new(packet, current_time, from_port))
|
||||
|
||||
func _register_client_if_new(port: int) -> void:
|
||||
if _client_peers.has(port):
|
||||
return
|
||||
|
||||
# Create a dedicated peer for this client
|
||||
var client_peer = PacketPeerUDP.new()
|
||||
client_peer.set_dest_address(hostname, server_port)
|
||||
_client_peers[port] = client_peer
|
||||
|
||||
func _process_client_to_server_packets(send_threshold: int) -> void:
|
||||
var packets_to_keep: Array[QueueEntry] = []
|
||||
|
||||
for entry in _client_to_server_queue:
|
||||
if send_threshold < entry.queued_at:
|
||||
packets_to_keep.append(entry)
|
||||
else:
|
||||
if _should_send_packet():
|
||||
var peer = _client_peers[entry.source_port] as PacketPeerUDP
|
||||
peer.put_packet(entry.packet_data)
|
||||
|
||||
_client_to_server_queue = packets_to_keep
|
||||
|
||||
func _read_server_to_client_packets(current_time: int) -> void:
|
||||
for client_port in _client_peers.keys():
|
||||
var client_peer = _client_peers[client_port] as PacketPeerUDP
|
||||
|
||||
while client_peer.get_available_packet_count() > 0:
|
||||
var packet = client_peer.get_packet()
|
||||
var err = client_peer.get_packet_error()
|
||||
|
||||
if err != OK:
|
||||
_logger.error("UDP proxy server-to-client packet error from port %s : %s", [client_port, err])
|
||||
continue
|
||||
|
||||
_server_to_client_queue.push_back(QueueEntry.new(packet, current_time, client_port))
|
||||
|
||||
func _process_server_to_client_queue(send_threshold: int) -> void:
|
||||
var packets_to_keep: Array[QueueEntry] = []
|
||||
|
||||
for entry in _server_to_client_queue:
|
||||
if send_threshold < entry.queued_at:
|
||||
packets_to_keep.append(entry)
|
||||
else:
|
||||
if _should_send_packet():
|
||||
_udp_proxy_server.set_dest_address(hostname, entry.source_port)
|
||||
_udp_proxy_server.put_packet(entry.packet_data)
|
||||
|
||||
_server_to_client_queue = packets_to_keep
|
||||
|
||||
# Send packet or simulate loss
|
||||
func _should_send_packet() -> bool:
|
||||
return packet_loss_percent <= 0.0 or _rng_packet_loss.randf() >= (packet_loss_percent / 100.0)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if _proxy_thread and _proxy_thread.is_started():
|
||||
_proxy_loop_enabled = false
|
||||
_proxy_thread.wait_to_finish()
|
||||
@@ -0,0 +1 @@
|
||||
uid://1y2ey1e6qjwr
|
||||
@@ -0,0 +1,72 @@
|
||||
extends PhysicsDriver
|
||||
|
||||
class_name PhysicsDriver2D
|
||||
|
||||
# Physics driver based on netfox ticks
|
||||
# Requires a custom build of Godot with https://github.com/godotengine/godot/pull/76462
|
||||
|
||||
var scene_collision_objects: Array = []
|
||||
var collision_objects_snapshots: Dictionary[int, Dictionary] = {}
|
||||
|
||||
func _init_physics_space() -> void:
|
||||
physics_space = get_viewport().world_2d.space
|
||||
PhysicsServer2D.space_set_active(physics_space, false)
|
||||
|
||||
get_tree().node_added.connect(node_added)
|
||||
get_tree().node_removed.connect(node_removed)
|
||||
scan_tree()
|
||||
|
||||
func _physics_step(delta) -> void:
|
||||
PhysicsServer2D.space_flush_queries(physics_space)
|
||||
PhysicsServer2D.space_step(physics_space, delta)
|
||||
|
||||
func _snapshot_space(tick: int) -> void:
|
||||
var rid_states: Dictionary[RID, Array] = {}
|
||||
for element in scene_collision_objects:
|
||||
if element is CharacterBody2D:
|
||||
element.force_update_transform() # force colliders to update
|
||||
|
||||
var rid = element.get_rid()
|
||||
rid_states[rid] = get_body_states(rid)
|
||||
snapshots[tick] = rid_states
|
||||
|
||||
func _rollback_space(tick) -> void:
|
||||
if snapshots.has(tick):
|
||||
var rid_states = snapshots[tick]
|
||||
for rid in rid_states.keys():
|
||||
set_body_states(rid, rid_states[rid])
|
||||
|
||||
for body in scene_collision_objects:
|
||||
if body is CharacterBody2D or body is AnimatableBody2D:
|
||||
body.force_update_transform() # force colliders to update
|
||||
|
||||
func get_body_states(rid: RID) -> Array:
|
||||
var body_state: Array = [Vector3.ZERO, Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO]
|
||||
body_state[0] = PhysicsServer2D.body_get_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM)
|
||||
body_state[1] = PhysicsServer2D.body_get_state(rid, PhysicsServer2D.BODY_STATE_LINEAR_VELOCITY)
|
||||
body_state[2] = PhysicsServer2D.body_get_state(rid, PhysicsServer2D.BODY_STATE_ANGULAR_VELOCITY)
|
||||
body_state[3] = PhysicsServer2D.body_get_state(rid, PhysicsServer2D.BODY_STATE_SLEEPING)
|
||||
return body_state
|
||||
|
||||
func set_body_states(rid: RID, body_state: Array) -> void:
|
||||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, body_state[0])
|
||||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_LINEAR_VELOCITY, body_state[1])
|
||||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_ANGULAR_VELOCITY, body_state[2])
|
||||
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_SLEEPING, body_state[3])
|
||||
|
||||
func scan_tree():
|
||||
scene_collision_objects.clear()
|
||||
scene_collision_objects = get_all_children(get_node('/root'))
|
||||
|
||||
func get_all_children(in_node: Node) -> Array:
|
||||
var nodes = []
|
||||
nodes = in_node.find_children("*", "PhysicsBody2D", true, false)
|
||||
return nodes
|
||||
|
||||
func node_added(node: Node) -> void:
|
||||
if node is PhysicsBody2D:
|
||||
scene_collision_objects.append(node)
|
||||
|
||||
func node_removed(node: Node) -> void:
|
||||
if node is PhysicsBody2D:
|
||||
scene_collision_objects.erase(node)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8p8gymii2y5v
|
||||
@@ -0,0 +1,71 @@
|
||||
extends PhysicsDriver
|
||||
class_name PhysicsDriver3D
|
||||
|
||||
# Physics driver based on netfox ticks
|
||||
# Requires a custom build of Godot with https://github.com/godotengine/godot/pull/76462
|
||||
|
||||
# Maps ticks ( int ) to global snapshots ( Dictionary<RID, Array> )
|
||||
var scene_collision_objects: Array = []
|
||||
|
||||
func _init_physics_space() -> void:
|
||||
physics_space = get_viewport().world_3d.space
|
||||
PhysicsServer3D.space_set_active(physics_space, false)
|
||||
|
||||
get_tree().node_added.connect(node_added)
|
||||
get_tree().node_removed.connect(node_removed)
|
||||
scan_tree()
|
||||
|
||||
func _physics_step(delta) -> void:
|
||||
PhysicsServer3D.space_flush_queries(physics_space)
|
||||
PhysicsServer3D.space_step(physics_space, delta)
|
||||
|
||||
func _snapshot_space(tick: int) -> void:
|
||||
# Maps RIDs to physics state ( Array )
|
||||
var rid_states := {}
|
||||
for element in scene_collision_objects:
|
||||
var rid = element.get_rid()
|
||||
rid_states[rid] = get_body_states(rid)
|
||||
|
||||
snapshots[tick] = rid_states
|
||||
|
||||
func _rollback_space(tick) -> void:
|
||||
if snapshots.has(tick):
|
||||
var rid_states = snapshots[tick]
|
||||
for rid in rid_states.keys():
|
||||
set_body_states(rid, rid_states[rid])
|
||||
|
||||
for body in scene_collision_objects:
|
||||
if body is CharacterBody3D or body is AnimatableBody3D:
|
||||
body.force_update_transform()
|
||||
|
||||
|
||||
func get_body_states(rid: RID) -> Array:
|
||||
var body_state: Array = [Vector3.ZERO, Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO]
|
||||
body_state[0] = PhysicsServer3D.body_get_state(rid, PhysicsServer3D.BODY_STATE_TRANSFORM)
|
||||
body_state[1] = PhysicsServer3D.body_get_state(rid, PhysicsServer3D.BODY_STATE_LINEAR_VELOCITY)
|
||||
body_state[2] = PhysicsServer3D.body_get_state(rid, PhysicsServer3D.BODY_STATE_ANGULAR_VELOCITY)
|
||||
body_state[3] = PhysicsServer3D.body_get_state(rid, PhysicsServer3D.BODY_STATE_SLEEPING)
|
||||
return body_state
|
||||
|
||||
func set_body_states(rid: RID, body_state: Array) -> void:
|
||||
PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_TRANSFORM, body_state[0])
|
||||
PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_LINEAR_VELOCITY, body_state[1])
|
||||
PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_ANGULAR_VELOCITY, body_state[2])
|
||||
PhysicsServer3D.body_set_state(rid, PhysicsServer3D.BODY_STATE_SLEEPING, body_state[3])
|
||||
|
||||
func scan_tree():
|
||||
scene_collision_objects.clear()
|
||||
scene_collision_objects = get_all_children(get_node('/root'))
|
||||
|
||||
func get_all_children(in_node: Node) -> Array:
|
||||
var nodes = []
|
||||
nodes = in_node.find_children("*", "PhysicsBody3D", true, false)
|
||||
return nodes
|
||||
|
||||
func node_added(node: Node) -> void:
|
||||
if node is PhysicsBody3D:
|
||||
scene_collision_objects.append(node)
|
||||
|
||||
func node_removed(node: Node) -> void:
|
||||
if node is PhysicsBody3D:
|
||||
scene_collision_objects.erase(node)
|
||||
@@ -0,0 +1 @@
|
||||
uid://wr5ur5dqrkni
|
||||
@@ -0,0 +1,44 @@
|
||||
@icon("res://addons/netfox.extras/icons/network-rigid-body-2d.svg")
|
||||
extends RigidBody2D
|
||||
class_name NetworkRigidBody2D
|
||||
|
||||
## A rollback / state synchronizer class for RigidBody2D.
|
||||
## Set state property path to physics_state to synchronize the state of this body.
|
||||
|
||||
@onready var direct_state = PhysicsServer2D.body_get_direct_state(get_rid())
|
||||
|
||||
var physics_state: Array:
|
||||
get: return get_state()
|
||||
set(v): set_state(v)
|
||||
|
||||
enum {
|
||||
ORIGIN,
|
||||
ROT,
|
||||
LIN_VEL,
|
||||
ANG_VEL,
|
||||
SLEEPING
|
||||
}
|
||||
|
||||
func _notification(notification: int):
|
||||
if notification == NOTIFICATION_READY:
|
||||
add_to_group("network_rigid_body")
|
||||
|
||||
func get_state() -> Array:
|
||||
var body_state: Array = [Vector3.ZERO, Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO, false]
|
||||
body_state[ORIGIN] = direct_state.transform.origin
|
||||
body_state[ROT] = direct_state.transform.get_rotation()
|
||||
body_state[LIN_VEL] = direct_state.linear_velocity
|
||||
body_state[ANG_VEL] = direct_state.angular_velocity
|
||||
body_state[SLEEPING] = direct_state.sleeping
|
||||
return body_state
|
||||
|
||||
func set_state(remote_state: Array) -> void:
|
||||
direct_state.transform = Transform2D(remote_state[ROT], remote_state[ORIGIN])
|
||||
direct_state.linear_velocity = remote_state[LIN_VEL]
|
||||
direct_state.angular_velocity = remote_state[ANG_VEL]
|
||||
direct_state.sleeping = remote_state[SLEEPING]
|
||||
|
||||
## Override and apply any logic, forces or impulses to the rigid body as you would in physics_process
|
||||
## The physics engine will run its simulation during rollback_tick with other nodes
|
||||
func _physics_rollback_tick(_delta, _tick):
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8hw7ol53m55g
|
||||
@@ -0,0 +1,46 @@
|
||||
@icon("res://addons/netfox.extras/physics/network-rigid-body-3d.gd")
|
||||
extends RigidBody3D
|
||||
class_name NetworkRigidBody3D
|
||||
|
||||
## A rollback / state synchronizer class for RigidBody3D.
|
||||
## Set state property path to physics_state to synchronize the state of this body.
|
||||
|
||||
@onready var direct_state = PhysicsServer3D.body_get_direct_state(get_rid())
|
||||
|
||||
var physics_state: Array:
|
||||
get: return get_state()
|
||||
set(v): set_state(v)
|
||||
|
||||
enum {
|
||||
ORIGIN,
|
||||
QUAT,
|
||||
LIN_VEL,
|
||||
ANG_VEL,
|
||||
SLEEPING
|
||||
}
|
||||
|
||||
func _notification(notification: int):
|
||||
if notification == NOTIFICATION_READY:
|
||||
add_to_group("network_rigid_body")
|
||||
|
||||
func get_state() -> Array:
|
||||
var body_state: Array = [Vector3.ZERO, Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO, false]
|
||||
body_state[ORIGIN] = direct_state.transform.origin
|
||||
body_state[QUAT] = direct_state.transform.basis.get_rotation_quaternion()
|
||||
body_state[LIN_VEL] = direct_state.linear_velocity
|
||||
body_state[ANG_VEL] = direct_state.angular_velocity
|
||||
body_state[SLEEPING] = direct_state.sleeping
|
||||
return body_state
|
||||
|
||||
func set_state(remote_state: Array) -> void:
|
||||
direct_state.transform.origin = remote_state[ORIGIN]
|
||||
direct_state.transform.basis = Basis(remote_state[QUAT])
|
||||
direct_state.linear_velocity = remote_state[LIN_VEL]
|
||||
direct_state.angular_velocity = remote_state[ANG_VEL]
|
||||
direct_state.sleeping = remote_state[SLEEPING]
|
||||
|
||||
|
||||
## Override and apply any logic, forces or impulses to the rigid body as you would in physics_process
|
||||
## The physics engine will run its simulation during rollback_tick with other nodes
|
||||
func _physics_rollback_tick(_delta, _tick):
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://bklxcdyxgbjg2
|
||||
@@ -0,0 +1,88 @@
|
||||
extends Object
|
||||
|
||||
class PhysicsDriverToggle:
|
||||
const INACTIVE_SUFFIX := ".off"
|
||||
|
||||
func get_name() -> String:
|
||||
return "???"
|
||||
|
||||
func get_files() -> Array[String]:
|
||||
return []
|
||||
|
||||
func get_error_messages() -> Array[String]:
|
||||
return []
|
||||
|
||||
func is_enabled() -> bool:
|
||||
return get_files().any(func(it): return FileAccess.file_exists(it))
|
||||
|
||||
func toggle() -> Array[String]:
|
||||
var errors := get_error_messages()
|
||||
if not errors.is_empty():
|
||||
return errors
|
||||
|
||||
var enable := not is_enabled()
|
||||
|
||||
var uid_files := get_files().map(func(it): return it + ".uid")
|
||||
var renames = (get_files() + uid_files).map(func(it):
|
||||
if enable: return [it + INACTIVE_SUFFIX, it]
|
||||
else: return [it, it + INACTIVE_SUFFIX]
|
||||
)
|
||||
|
||||
for rename in renames:
|
||||
var result := DirAccess.rename_absolute(rename[0], rename[1])
|
||||
if result != OK:
|
||||
errors.append(
|
||||
"Failed rename \"%s\" -> \"%s\"; reason: %s" %
|
||||
[rename[0], rename[1], error_string(result)]
|
||||
)
|
||||
return errors
|
||||
|
||||
class Rapier2DPhysicsDriverToggle extends PhysicsDriverToggle:
|
||||
func get_name() -> String:
|
||||
return "Rapier2D"
|
||||
|
||||
func get_files() -> Array[String]:
|
||||
return [
|
||||
"res://addons/netfox.extras/physics/rapier_driver_2d.gd",
|
||||
]
|
||||
|
||||
func get_error_messages() -> Array[String]:
|
||||
if not ClassDB.class_exists("RapierPhysicsServer2D"):
|
||||
return ["Rapier physics is not available! Is the extension installed?"]
|
||||
return []
|
||||
|
||||
class Rapier3DPhysicsDriverToggle extends PhysicsDriverToggle:
|
||||
func get_name() -> String:
|
||||
return "Rapier3D"
|
||||
|
||||
func get_files() -> Array[String]:
|
||||
return [
|
||||
"res://addons/netfox.extras/physics/rapier_driver_3d.gd",
|
||||
]
|
||||
|
||||
func get_error_messages() -> Array[String]:
|
||||
if not ClassDB.class_exists("RapierPhysicsServer3D"):
|
||||
return ["Rapier physics is not available! Is the extension installed?"]
|
||||
return []
|
||||
|
||||
class GodotPhysicsDriverToggle extends PhysicsDriverToggle:
|
||||
func get_name() -> String:
|
||||
return "Godot"
|
||||
|
||||
func get_files() -> Array[String]:
|
||||
return [
|
||||
"res://addons/netfox.extras/physics/godot_driver_3d.gd",
|
||||
"res://addons/netfox.extras/physics/godot_driver_2d.gd"
|
||||
]
|
||||
|
||||
func get_error_messages() -> Array[String]:
|
||||
if not PhysicsServer3D.has_method("space_step") or not PhysicsServer2D.has_method("space_step"):
|
||||
return ["Physics stepping is not available! Is this the right Godot build?"]
|
||||
return []
|
||||
|
||||
static func all() -> Array[PhysicsDriverToggle]:
|
||||
return [
|
||||
Rapier2DPhysicsDriverToggle.new(),
|
||||
Rapier3DPhysicsDriverToggle.new(),
|
||||
GodotPhysicsDriverToggle.new()
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
uid://bu4ppfj0ovkbr
|
||||
@@ -0,0 +1,86 @@
|
||||
extends Node
|
||||
|
||||
class_name PhysicsDriver
|
||||
|
||||
# Physics driver based on netfox ticks
|
||||
# Step physics in time with netfox and participates in rollback
|
||||
|
||||
var physics_space: RID
|
||||
var snapshots: Dictionary = {}
|
||||
|
||||
# Number of physics steps to take per network tick
|
||||
@export var physics_factor: int = 2
|
||||
# Snapshot and Rollback entire physics space.
|
||||
@export var rollback_physics_space: bool = true
|
||||
|
||||
func _enter_tree():
|
||||
#regular ticks
|
||||
NetworkTime.before_tick.connect(before_tick)
|
||||
NetworkTime.after_tick_loop.connect(after_tick_loop)
|
||||
|
||||
#rollback ticks
|
||||
if rollback_physics_space:
|
||||
NetworkRollback.on_prepare_tick.connect(on_prepare_tick)
|
||||
NetworkRollback.on_process_tick.connect(on_process_tick)
|
||||
|
||||
func _exit_tree():
|
||||
NetworkTime.before_tick.disconnect(before_tick)
|
||||
NetworkTime.after_tick_loop.disconnect(after_tick_loop)
|
||||
|
||||
#rollback ticks
|
||||
if NetworkRollback.on_prepare_tick.is_connected(on_prepare_tick):
|
||||
NetworkRollback.on_prepare_tick.disconnect(on_prepare_tick)
|
||||
NetworkRollback.on_process_tick.disconnect(on_process_tick)
|
||||
|
||||
func _ready() -> void:
|
||||
_init_physics_space()
|
||||
|
||||
# Emitted before a tick is run.
|
||||
func before_tick(_delta: float, tick: int) -> void:
|
||||
_snapshot_space(tick)
|
||||
step_physics(_delta)
|
||||
|
||||
func on_prepare_tick(tick: int) -> void:
|
||||
if NetworkRollback._rollback_from == tick:
|
||||
# First tick of rollback loop, rewind
|
||||
_rollback_space(tick)
|
||||
else:
|
||||
# Subsequent ticks are re-writing history.
|
||||
_snapshot_space(tick)
|
||||
|
||||
func on_process_tick(_tick: int) -> void:
|
||||
step_physics(NetworkTime.ticktime)
|
||||
|
||||
func after_tick_loop() -> void:
|
||||
# Remove old snapshots
|
||||
for i in snapshots.keys():
|
||||
if i < NetworkRollback.history_start:
|
||||
snapshots.erase(i)
|
||||
|
||||
func step_physics(_delta: float) -> void:
|
||||
# Break up physics into smaller steps if needed
|
||||
var frac_delta = _delta / physics_factor
|
||||
var rollback_participants = get_tree().get_nodes_in_group("network_rigid_body")
|
||||
for i in range(physics_factor):
|
||||
for net_rigid_body in rollback_participants:
|
||||
net_rigid_body._physics_rollback_tick(frac_delta, NetworkTime.tick)
|
||||
|
||||
_physics_step(frac_delta)
|
||||
|
||||
## Override this method to initialize the physics space.
|
||||
func _init_physics_space() -> void:
|
||||
pass
|
||||
|
||||
## Override this method to take one step in the physics space.
|
||||
## [br][br]
|
||||
## It should also flush and update all Godot nodes
|
||||
func _physics_step(_delta) -> void:
|
||||
pass
|
||||
|
||||
## Override this method to record the current state of the physics space.
|
||||
func _snapshot_space(_tick: int) -> void:
|
||||
pass
|
||||
|
||||
## Override this method to restore the physics space to a previous state.
|
||||
func _rollback_space(_tick) -> void:
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcws6qk4hxtun
|
||||
@@ -0,0 +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:
|
||||
_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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bcc28dvk0pufe
|
||||
@@ -0,0 +1,37 @@
|
||||
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:
|
||||
_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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bo4lplbeepibj
|
||||
@@ -0,0 +1,7 @@
|
||||
[plugin]
|
||||
|
||||
name="netfox.extras"
|
||||
description="Game-specific utilities for Netfox"
|
||||
author="Tamas Galffy and contributors"
|
||||
version="1.40.2"
|
||||
script="netfox-extras.gd"
|
||||
@@ -0,0 +1,71 @@
|
||||
extends RefCounted
|
||||
class_name RewindableRandomNumberGenerator
|
||||
|
||||
## Provides methods for generating pseudo-random numbers in the rollback tick
|
||||
## loop.
|
||||
##
|
||||
## Using a regular [RandomNumberGenerator] in [code]_rollback_tick()[/code]
|
||||
## would generate different numbers on each peer. It also generates different
|
||||
## numbers when resimulating the same tick.
|
||||
## [br][br]
|
||||
## This class solves all of the above, making it suitable for use during
|
||||
## rollback.
|
||||
## [br][br]
|
||||
## The seed must be provided on instantiation, and must be the same on all peers
|
||||
## for the random number generator to work properly.
|
||||
##
|
||||
## @tutorial(RewindableRandomNumberGenerator Guide): https://foxssake.github.io/netfox/latest/netfox.extras/guides/rewindable-random-number-generator/
|
||||
|
||||
var _rng: RandomNumberGenerator
|
||||
var _last_reset_tick := -1
|
||||
var _last_reset_rollback_tick := -1
|
||||
|
||||
static var _logger := NetfoxLogger._for_extras("RewindableRandomNumberGenerator")
|
||||
|
||||
func _init(p_seed: int):
|
||||
_rng = RandomNumberGenerator.new()
|
||||
_rng.set_seed(p_seed)
|
||||
|
||||
## Returns a pseudo-random float between [code]0.0[/code] and [code]1.0[/code]
|
||||
## (inclusive).
|
||||
func randf() -> float:
|
||||
_ensure_state()
|
||||
return _rng.randf()
|
||||
|
||||
## Returns a pseudo-random float between [code]from[/code] and [code]to[/code]
|
||||
## (inclusive).
|
||||
func randf_range(from: float, to: float) -> float:
|
||||
_ensure_state()
|
||||
return _rng.randf_range(from, to)
|
||||
|
||||
## Returns a normally-distributed, pseudo-random floating-point number from the
|
||||
## specified [code]mean[/code] and a standard [code]deviation[/code]. This is
|
||||
## also known as a Gaussian distribution.
|
||||
func randfn(mean: float = 0.0, deviation: float = 1.0) -> float:
|
||||
_ensure_state()
|
||||
return _rng.randfn(mean, deviation)
|
||||
|
||||
## Returns a pseudo-random 32-bit unsigned integer between [code]0[/code] and
|
||||
## [code]4294967295[/code] (inclusive).
|
||||
func randi() -> int:
|
||||
_ensure_state()
|
||||
return _rng.randi()
|
||||
|
||||
## Returns a pseudo-random 32-bit unsigned integer between [code]from[/code] and
|
||||
## [code]to[/code] (inclusive).
|
||||
func randi_range(from: int, to: int) -> int:
|
||||
_ensure_state()
|
||||
return _rng.randi_range(from, to)
|
||||
|
||||
func _ensure_state() -> void:
|
||||
if NetworkTime.tick == _last_reset_tick and NetworkRollback.tick == _last_reset_rollback_tick:
|
||||
# State already has been set
|
||||
return
|
||||
|
||||
if NetworkRollback.is_rollback():
|
||||
_rng.state = hash([_rng.seed, NetworkRollback.tick])
|
||||
else:
|
||||
_rng.state = hash([_rng.seed, NetworkTime.tick])
|
||||
|
||||
_last_reset_rollback_tick = NetworkRollback.tick
|
||||
_last_reset_tick = NetworkTime.tick
|
||||
@@ -0,0 +1 @@
|
||||
uid://cqj8glnpkt5po
|
||||
@@ -0,0 +1,187 @@
|
||||
@tool
|
||||
@icon("res://addons/netfox.extras/icons/rewindable-state-machine.svg")
|
||||
extends Node
|
||||
class_name RewindableStateMachine
|
||||
|
||||
## A state machine that can be used with rollback.
|
||||
##
|
||||
## It relies on [RollbackSynchronizer] to manage its state. State transitions
|
||||
## are only triggered by gameplay code, and not by rollback reverting to an
|
||||
## earlier state.
|
||||
## [br][br]
|
||||
## For this node to work correctly, a [RollbackSynchronizer] must be added as
|
||||
## a sibling, and it must have the [RewindableStateMachine]'s [member state]
|
||||
## property configured as a state property.
|
||||
## [br][br]
|
||||
## To implement states, extend the [RewindableState] class and add it as a child
|
||||
## node.
|
||||
##
|
||||
## @tutorial(RewindableStateMachine Guide): https://foxssake.github.io/netfox/latest/netfox.extras/guides/rewindable-state-machine/
|
||||
|
||||
## Name of the current state.
|
||||
##
|
||||
## Can be an empty string if no state is active. Only modify directly if you
|
||||
## need to skip [method transition]'s callbacks.
|
||||
@export var state: StringName = "":
|
||||
get: return _state_object.name if _state_object != null else ""
|
||||
set(v): _set_state(v)
|
||||
|
||||
## Emitted during state transitions.
|
||||
##
|
||||
## This signal can be used to run gameplay code on state changes.
|
||||
## [br][br]
|
||||
## This signal is emitted whenever a transition happens during rollback, which
|
||||
## means it may be emitted multiple times for the same transition if it gets
|
||||
## resimulated during rollback.
|
||||
## [br][br]
|
||||
## [b]State changes are not necessarily emitted on all peers.[/b]
|
||||
## See: [url=https://foxssake.github.io/netfox/netfox.extras/guides/rewindable-state-machine/#caveats]RewindableStateMachine caveats[/url]
|
||||
signal on_state_changed(old_state: RewindableState, new_state: RewindableState)
|
||||
|
||||
## Emitted after the displayed state has changed.
|
||||
##
|
||||
## This signal can be used to update visuals based on state changes.
|
||||
## [br][br]
|
||||
## This signal is emitted whenever the state after a tick loop has changed.
|
||||
signal on_display_state_changed(old_state: RewindableState, new_state: RewindableState)
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_extras("RewindableStateMachine")
|
||||
|
||||
var _state_object: RewindableState = null
|
||||
var _previous_state_object: RewindableState = null
|
||||
var _available_states: Dictionary = {}
|
||||
var _prevent_transition: bool = false
|
||||
var _prevent_callable: Callable = func(): _prevent_transition = true
|
||||
|
||||
## Transition to a new state specified by [param new_state_name] and return
|
||||
## true.
|
||||
##
|
||||
## Finds the given state by name and transitions to it if possible. The new
|
||||
## state's [method RewindableState.can_enter] callback decides if it can be
|
||||
## entered from the current state.
|
||||
## [br][br]
|
||||
## Upon transitioning, [method RewindableState.exit] is called on the old state,
|
||||
## and [method RewindableState.enter] is called on the new state. In addition,
|
||||
## [signal on_state_changed] is emitted.
|
||||
## [br][br]
|
||||
## Does nothing if transitioning to the currently active state. Emits a warning
|
||||
## and does nothing when transitioning to an unknown state.
|
||||
func transition(new_state_name: StringName) -> bool:
|
||||
# Check if target state is valid
|
||||
if state == new_state_name:
|
||||
return false
|
||||
|
||||
if not _available_states.has(new_state_name):
|
||||
_logger.warning("Attempted to transition from state '%s' into unknown state '%s'", [state, new_state_name])
|
||||
return false
|
||||
|
||||
var from_state = _state_object
|
||||
var new_state: RewindableState = _available_states[new_state_name]
|
||||
_prevent_transition = false
|
||||
|
||||
# Validate transition
|
||||
if from_state:
|
||||
if !new_state.can_enter(_state_object):
|
||||
return false
|
||||
|
||||
# Emit exit signal, allow handlers to prevent transition
|
||||
_state_object.on_exit.emit(new_state, NetworkRollback.tick, _prevent_callable)
|
||||
if _prevent_transition: return false
|
||||
|
||||
new_state.on_enter.emit(from_state, NetworkRollback.tick, _prevent_callable)
|
||||
if _prevent_transition: return false
|
||||
|
||||
# Transition valid, run callbacks
|
||||
if is_instance_valid(from_state):
|
||||
from_state.exit(new_state, NetworkRollback.tick)
|
||||
new_state.enter(from_state, NetworkRollback.tick)
|
||||
|
||||
# Set new state
|
||||
_state_object = new_state
|
||||
on_state_changed.emit(from_state, new_state)
|
||||
|
||||
return true
|
||||
|
||||
## Update the internal cache of known states
|
||||
## [br][br]
|
||||
## Automatically called on ready and when a child node is added or removed. Call
|
||||
## manually to force an update.
|
||||
func update_states() -> void:
|
||||
_available_states.clear()
|
||||
|
||||
for child in find_children("*", "RewindableState", false):
|
||||
_available_states[child.name] = child
|
||||
|
||||
func _notification(what: int):
|
||||
# Use notification instead of _ready, so users can write their own _ready
|
||||
# callback without having to call super()
|
||||
if Engine.is_editor_hint(): return
|
||||
|
||||
match what:
|
||||
NOTIFICATION_CHILD_ORDER_CHANGED:
|
||||
update_states()
|
||||
NOTIFICATION_ENTER_TREE:
|
||||
# Compare states after tick loop
|
||||
NetworkTime.after_tick_loop.connect(_after_tick_loop)
|
||||
update_states()
|
||||
NOTIFICATION_EXIT_TREE:
|
||||
# Disconnect handlers
|
||||
NetworkTime.after_tick_loop.disconnect(_after_tick_loop)
|
||||
|
||||
func _get_configuration_warnings():
|
||||
const MISSING_SYNCHRONIZER_ERROR := \
|
||||
"RewindableStateMachine is not managed by a RollbackSynchronizer! Add it as a sibling node to fix this."
|
||||
const INVALID_SYNCHRONIZER_CONFIG_ERROR := \
|
||||
"RollbackSynchronizer configuration is invalid, it can't manage this state machine!" +\
|
||||
"\nNote: You may need to reload this scene after fixing for this warning to disappear."
|
||||
const MISSING_PROPERTY_ERROR := \
|
||||
"State is not managed by RollbackSynchronizer! Add `state` property to the synchronizer to fix this. " +\
|
||||
"\nNote: You may need to reload this scene after fixing for this warning to disappear."
|
||||
|
||||
# Check if there's a rollback synchronizer
|
||||
var rollback_synchronizer_node = get_parent().find_children("*", "RollbackSynchronizer", false).pop_front()
|
||||
if not rollback_synchronizer_node:
|
||||
return [MISSING_SYNCHRONIZER_ERROR]
|
||||
|
||||
var rollback_synchronizer := rollback_synchronizer_node as RollbackSynchronizer
|
||||
|
||||
# Check if its configuration is valid
|
||||
# TODO: Expose this as a property?
|
||||
if not rollback_synchronizer.root:
|
||||
return [INVALID_SYNCHRONIZER_CONFIG_ERROR]
|
||||
|
||||
# Check if it manages our `state` property
|
||||
for state_property_path in rollback_synchronizer.state_properties:
|
||||
var property = PropertyEntry.parse(rollback_synchronizer.root, state_property_path)
|
||||
if property.node == self and property.property == "state":
|
||||
return []
|
||||
|
||||
return [MISSING_PROPERTY_ERROR]
|
||||
|
||||
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
|
||||
if _state_object:
|
||||
_state_object.tick(delta, tick, is_fresh)
|
||||
_state_object.on_tick.emit(delta, tick, is_fresh)
|
||||
|
||||
func _after_tick_loop():
|
||||
if _state_object != _previous_state_object:
|
||||
on_display_state_changed.emit(_previous_state_object, _state_object)
|
||||
|
||||
if _previous_state_object:
|
||||
_previous_state_object.on_display_exit.emit(_state_object, NetworkTime.tick)
|
||||
_previous_state_object.display_exit(_state_object, NetworkTime.tick)
|
||||
|
||||
_state_object.on_display_enter.emit(_previous_state_object, NetworkTime.tick)
|
||||
_state_object.display_enter(_previous_state_object, NetworkTime.tick)
|
||||
|
||||
_previous_state_object = _state_object
|
||||
|
||||
func _set_state(new_state: StringName) -> void:
|
||||
if not new_state:
|
||||
return
|
||||
|
||||
if not _available_states.has(new_state):
|
||||
_logger.warning("Attempted to jump to unknown state: %s", [new_state])
|
||||
return
|
||||
|
||||
_state_object = _available_states[new_state]
|
||||
@@ -0,0 +1 @@
|
||||
uid://byrgwv2o7hstx
|
||||
@@ -0,0 +1,114 @@
|
||||
@tool
|
||||
@icon("res://addons/netfox.extras/icons/rewindable-state.svg")
|
||||
extends Node
|
||||
class_name RewindableState
|
||||
|
||||
## Base class for states to be used with [RewindableStateMachine].
|
||||
##
|
||||
## Provides multiple callback methods for a state's lifecycle, which can be
|
||||
## overridden by extending classes.
|
||||
## [br][br]
|
||||
## Must have a [RewindableStateMachine] as a parent.
|
||||
##
|
||||
## @tutorial(RewindableStateMachine Guide): https://foxssake.github.io/netfox/latest/netfox.extras/guides/rewindable-state-machine/
|
||||
|
||||
## Emitted when entering the state
|
||||
signal on_enter(previous_state: RewindableState, tick: int, prevent: Callable)
|
||||
|
||||
## Emitted on every rollback tick while the state is active
|
||||
signal on_tick(delta: float, tick: int, is_fresh: bool)
|
||||
|
||||
## Emitted when exiting the state
|
||||
signal on_exit(next_state: RewindableState, tick: int, prevent: Callable)
|
||||
|
||||
## Emitted before displaying this state
|
||||
signal on_display_enter(previous_state: RewindableState, tick: int)
|
||||
|
||||
## Emitted before displaying another state
|
||||
signal on_display_exit(next_state: RewindableState, tick: int)
|
||||
|
||||
## The [RewindableStateMachine] this state belongs to.
|
||||
## [br][br]
|
||||
## [i]read-only[/i]
|
||||
var state_machine: RewindableStateMachine:
|
||||
get: return _state_machine
|
||||
|
||||
var _state_machine: RewindableStateMachine
|
||||
|
||||
## Callback to run a single tick.
|
||||
##
|
||||
## This method is called by the [RewindableStateMachine] during the rollback
|
||||
## tick loop to update game state.
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement game logic
|
||||
func tick(delta: float, tick: int, is_fresh: bool) -> void:
|
||||
pass
|
||||
|
||||
## Callback for entering the state.
|
||||
##
|
||||
## This method is called whenever the state machine enters this state.
|
||||
## [br][br]
|
||||
## It is best practice to only modify game state here, i.e. properties that are
|
||||
## configured as state in a [RollbackSynchronizer].
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement game logic reacting to state transitions
|
||||
func enter(previous_state: RewindableState, tick: int) -> void:
|
||||
pass
|
||||
|
||||
## Callback for exiting the state.
|
||||
##
|
||||
## This method is called whenever the state machine exits this state.
|
||||
## [br][br]
|
||||
## It is best practice to only modify game state here, i.e. properties that are
|
||||
## configured as state in a [RollbackSynchronizer].
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement game logic reacting to state transitions
|
||||
func exit(next_state: RewindableState, tick: int) -> void:
|
||||
pass
|
||||
|
||||
## Callback for validating state transitions.
|
||||
##
|
||||
## Whenever the [RewindableStateMachine] attempts to enter this state, it will
|
||||
## call this method to ensure that the transition is valid.
|
||||
## [br][br]
|
||||
## If this method returns true, the transition is valid and the state machine
|
||||
## will enter this state. Otherwise, the transition is invalid, and nothing
|
||||
## happens.
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement custom transition validation logic
|
||||
func can_enter(previous_state: RewindableState) -> bool:
|
||||
# Add your validation logic here
|
||||
# Return true if the state machine can transition to the next state
|
||||
return true
|
||||
|
||||
## Callback for displaying the state.
|
||||
##
|
||||
## After each tick loop, the [RewindableStateMachine] checks the final state,
|
||||
## i.e. the state that will be active until the next tick loop. If that state
|
||||
## has changed [b]to[/b] this one, the [RewindableStateMachine] will call this
|
||||
## method.
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement visuals / effects reacting to state transitions
|
||||
func display_enter(previous_state: RewindableState, tick: int) -> void:
|
||||
pass
|
||||
|
||||
## Callback for displaying a different state.
|
||||
##
|
||||
## After each tick loop, the [RewindableStateMachine] checks the final state,
|
||||
## i.e. the state that will be active until the next tick loop. If that state
|
||||
## has changed [b]from[/b] this one, the [RewindableStateMachine] will call this
|
||||
## method.
|
||||
## [br][br]
|
||||
## [i]override[/i] to implement visuals / effects reacting to state transitions
|
||||
func display_exit(next_state: RewindableState, tick: int) -> void:
|
||||
pass
|
||||
|
||||
func _get_configuration_warnings():
|
||||
return [] if get_parent() is RewindableStateMachine else ["This state should be a child of a RewindableStateMachine."]
|
||||
|
||||
func _notification(what: int):
|
||||
# Use notification instead of _ready, so users can write their own _ready
|
||||
# callback without having to call super()
|
||||
if what == NOTIFICATION_READY:
|
||||
if _state_machine == null and get_parent() is RewindableStateMachine:
|
||||
_state_machine = get_parent()
|
||||
@@ -0,0 +1 @@
|
||||
uid://usyufdtn83hc
|
||||
@@ -0,0 +1,75 @@
|
||||
extends Node2D
|
||||
class_name NetworkWeapon2D
|
||||
|
||||
## A 2D-specific implementation of [NetworkWeapon].
|
||||
|
||||
## Distance to consider too large during reconciliation checks.
|
||||
@export var distance_threshold: float = 1.0
|
||||
|
||||
var _weapon: _NetworkWeaponProxy
|
||||
|
||||
func can_fire() -> bool:
|
||||
return _weapon.can_fire()
|
||||
|
||||
func fire() -> Node2D:
|
||||
return _weapon.fire()
|
||||
|
||||
func get_fired_tick() -> int:
|
||||
return _weapon.get_fired_tick()
|
||||
|
||||
func _init():
|
||||
_weapon = _NetworkWeaponProxy.new()
|
||||
add_child(_weapon, true, INTERNAL_MODE_BACK)
|
||||
_weapon.owner = self
|
||||
|
||||
_weapon.c_can_fire = _can_fire
|
||||
_weapon.c_can_peer_use = _can_peer_use
|
||||
_weapon.c_after_fire = _after_fire
|
||||
_weapon.c_spawn = _spawn
|
||||
_weapon.c_get_data = _get_data
|
||||
_weapon.c_apply_data = _apply_data
|
||||
_weapon.c_is_reconcilable = _is_reconcilable
|
||||
_weapon.c_reconcile = _reconcile
|
||||
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_fire() -> bool:
|
||||
return false
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _after_fire(projectile: Node2D):
|
||||
pass
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _spawn() -> Node2D:
|
||||
return null
|
||||
|
||||
func _get_data(projectile: Node2D) -> Dictionary:
|
||||
return {
|
||||
"global_transform": projectile.global_transform
|
||||
}
|
||||
|
||||
func _apply_data(projectile: Node2D, data: Dictionary):
|
||||
projectile.global_transform = data["global_transform"]
|
||||
|
||||
func _is_reconcilable(projectile: Node2D, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
var req_transform = request_data["global_transform"] as Transform2D
|
||||
var loc_transform = local_data["global_transform"] as Transform2D
|
||||
|
||||
var request_pos = req_transform.origin
|
||||
var local_pos = loc_transform.origin
|
||||
|
||||
return request_pos.distance_to(local_pos) < distance_threshold
|
||||
|
||||
func _reconcile(projectile: Node2D, local_data: Dictionary, remote_data: Dictionary):
|
||||
var local_transform = local_data["global_transform"] as Transform2D
|
||||
var remote_transform = remote_data["global_transform"] as Transform2D
|
||||
|
||||
var relative_transform = projectile.global_transform * local_transform.inverse()
|
||||
var final_transform = remote_transform * relative_transform
|
||||
|
||||
projectile.global_transform = final_transform
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8pw311ku24we
|
||||
@@ -0,0 +1,75 @@
|
||||
extends Node3D
|
||||
class_name NetworkWeapon3D
|
||||
|
||||
## A 3D-specific implementation of [NetworkWeapon].
|
||||
|
||||
## Distance to consider too large during reconciliation checks.
|
||||
@export var distance_threshold: float = 1.0
|
||||
|
||||
var _weapon: _NetworkWeaponProxy
|
||||
|
||||
func can_fire() -> bool:
|
||||
return _weapon.can_fire()
|
||||
|
||||
func fire() -> Node3D:
|
||||
return _weapon.fire()
|
||||
|
||||
func get_fired_tick() -> int:
|
||||
return _weapon.get_fired_tick()
|
||||
|
||||
func _init():
|
||||
_weapon = _NetworkWeaponProxy.new()
|
||||
add_child(_weapon, true, INTERNAL_MODE_BACK)
|
||||
_weapon.owner = self
|
||||
|
||||
_weapon.c_can_fire = _can_fire
|
||||
_weapon.c_can_peer_use = _can_peer_use
|
||||
_weapon.c_after_fire = _after_fire
|
||||
_weapon.c_spawn = _spawn
|
||||
_weapon.c_get_data = _get_data
|
||||
_weapon.c_apply_data = _apply_data
|
||||
_weapon.c_is_reconcilable = _is_reconcilable
|
||||
_weapon.c_reconcile = _reconcile
|
||||
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_fire() -> bool:
|
||||
return false
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _after_fire(projectile: Node3D):
|
||||
pass
|
||||
|
||||
## See [NetworkWeapon]
|
||||
func _spawn() -> Node3D:
|
||||
return null
|
||||
|
||||
func _get_data(projectile: Node3D) -> Dictionary:
|
||||
return {
|
||||
"global_transform": projectile.global_transform
|
||||
}
|
||||
|
||||
func _apply_data(projectile: Node3D, data: Dictionary):
|
||||
projectile.global_transform = data["global_transform"]
|
||||
|
||||
func _is_reconcilable(projectile: Node3D, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
var req_transform = request_data["global_transform"] as Transform3D
|
||||
var loc_transform = local_data["global_transform"] as Transform3D
|
||||
|
||||
var request_pos = req_transform.origin
|
||||
var local_pos = loc_transform.origin
|
||||
|
||||
return request_pos.distance_to(local_pos) < distance_threshold
|
||||
|
||||
func _reconcile(projectile: Node3D, local_data: Dictionary, remote_data: Dictionary):
|
||||
var local_transform = local_data["global_transform"] as Transform3D
|
||||
var remote_transform = remote_data["global_transform"] as Transform3D
|
||||
|
||||
var relative_transform = projectile.global_transform * local_transform.inverse()
|
||||
var final_transform = remote_transform * relative_transform
|
||||
|
||||
projectile.global_transform = final_transform
|
||||
@@ -0,0 +1 @@
|
||||
uid://c1xyp6tx4hf3p
|
||||
@@ -0,0 +1,127 @@
|
||||
extends Node3D
|
||||
class_name NetworkWeaponHitscan3D
|
||||
|
||||
## A 3D-specific implementation of a networked hitscan (raycast) weapon.
|
||||
|
||||
## Maximum distance to cast the ray
|
||||
@export var max_distance: float = 1000.0
|
||||
|
||||
## Mask used to detect raycast hits
|
||||
@export_flags_3d_physics var collision_mask: int = 0xFFFFFFFF
|
||||
|
||||
## Colliders excluded from raycast hits
|
||||
@export var exclude: Array[RID] = []
|
||||
|
||||
var _weapon: _NetworkWeaponProxy
|
||||
|
||||
## Try to fire the weapon and return the projectile.
|
||||
## [br][br]
|
||||
## Returns true if the weapon was fired.
|
||||
func fire() -> bool:
|
||||
if not can_fire():
|
||||
return false
|
||||
|
||||
_apply_data(_get_data())
|
||||
_after_fire()
|
||||
return true
|
||||
|
||||
## Check whether this weapon can be fired.
|
||||
func can_fire() -> bool:
|
||||
return _weapon.can_fire()
|
||||
|
||||
func _init():
|
||||
_weapon = _NetworkWeaponProxy.new()
|
||||
add_child(_weapon, true, INTERNAL_MODE_BACK)
|
||||
_weapon.owner = self
|
||||
|
||||
_weapon.c_can_fire = _can_fire
|
||||
_weapon.c_can_peer_use = _can_peer_use
|
||||
_weapon.c_after_fire = _after_fire
|
||||
_weapon.c_spawn = _spawn
|
||||
_weapon.c_get_data = _get_data
|
||||
_weapon.c_apply_data = _apply_data
|
||||
_weapon.c_is_reconcilable = _is_reconcilable
|
||||
_weapon.c_reconcile = _reconcile
|
||||
|
||||
## Override this method with your own can fire logic.
|
||||
## [br][br]
|
||||
## See [NetworkWeapon].
|
||||
func _can_fire() -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to check if a given peer can use this weapon.
|
||||
## [br][br]
|
||||
## See [NetworkWeapon].
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to run any logic needed after successfully firing the
|
||||
## weapon.
|
||||
## [br][br]
|
||||
## See [NetworkWeapon].
|
||||
func _after_fire():
|
||||
pass
|
||||
|
||||
func _spawn():
|
||||
# No projectile is spawned for a hitscan weapon.
|
||||
pass
|
||||
|
||||
func _get_data() -> Dictionary:
|
||||
# Collect data needed to synchronize the firing event.
|
||||
return {
|
||||
"origin": global_transform.origin,
|
||||
"direction": -global_transform.basis.z # Assuming forward direction.
|
||||
}
|
||||
|
||||
func _apply_data(data: Dictionary):
|
||||
# Reproduces the firing event on all peers.
|
||||
var origin = data["origin"] as Vector3
|
||||
var direction = data["direction"] as Vector3
|
||||
|
||||
# Perform the raycast from origin in the given direction.
|
||||
var space_state = get_world_3d().direct_space_state
|
||||
|
||||
# Create a PhysicsRayQueryParameters3D object.
|
||||
var ray_params = PhysicsRayQueryParameters3D.new()
|
||||
ray_params.from = origin
|
||||
ray_params.to = origin + direction * max_distance
|
||||
|
||||
# Set collision masks or exclude objects:
|
||||
ray_params.collision_mask = collision_mask
|
||||
ray_params.exclude = exclude
|
||||
|
||||
var result = space_state.intersect_ray(ray_params)
|
||||
|
||||
if result:
|
||||
# Handle the hit result, such as spawning hit effects.
|
||||
_on_hit(result)
|
||||
|
||||
# Play firing effects on all peers.
|
||||
_on_fire()
|
||||
|
||||
func _is_reconcilable(request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
# Always reconcilable
|
||||
return true
|
||||
|
||||
func _reconcile(local_data: Dictionary, remote_data: Dictionary):
|
||||
# Nothing to do on reconcile
|
||||
pass
|
||||
|
||||
## Override to implement raycast hit logic.
|
||||
## [br][br]
|
||||
## The parameter is the result of a
|
||||
## [method PhysicsDirectSpaceState3D.intersect_ray] call.
|
||||
func _on_hit(result: Dictionary):
|
||||
# Implement hit effect logic here.
|
||||
# var hit_position = result.position
|
||||
# var hit_normal = result.normal
|
||||
# var collider = result.collider
|
||||
|
||||
# For example, you might emit a signal or instantiate a hit effect scene:
|
||||
# emit_signal("hit_detected", hit_position, hit_normal, collider)
|
||||
pass
|
||||
|
||||
## Override to implement firing effects, like muzzle flash or sound.
|
||||
func _on_fire():
|
||||
# Implement firing effect logic here.
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://daf6uabdxsfwq
|
||||
@@ -0,0 +1,35 @@
|
||||
extends NetworkWeapon
|
||||
class_name _NetworkWeaponProxy
|
||||
|
||||
var c_can_fire: Callable
|
||||
var c_can_peer_use: Callable
|
||||
var c_after_fire: Callable
|
||||
var c_spawn: Callable
|
||||
var c_get_data: Callable
|
||||
var c_apply_data: Callable
|
||||
var c_is_reconcilable: Callable
|
||||
var c_reconcile: Callable
|
||||
|
||||
func _can_fire() -> bool:
|
||||
return c_can_fire.call()
|
||||
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return c_can_peer_use.call(peer_id)
|
||||
|
||||
func _after_fire(projectile: Node):
|
||||
c_after_fire.call(projectile)
|
||||
|
||||
func _spawn() -> Node:
|
||||
return c_spawn.call()
|
||||
|
||||
func _get_data(projectile: Node) -> Dictionary:
|
||||
return c_get_data.call(projectile)
|
||||
|
||||
func _apply_data(projectile: Node, data: Dictionary):
|
||||
c_apply_data.call(projectile, data)
|
||||
|
||||
func _is_reconcilable(projectile: Node, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
return c_is_reconcilable.call(projectile, request_data, local_data)
|
||||
|
||||
func _reconcile(projectile: Node, local_data: Dictionary, remote_data: Dictionary):
|
||||
c_reconcile.call(projectile, local_data, remote_data)
|
||||
@@ -0,0 +1 @@
|
||||
uid://76n8gu7udnum
|
||||
@@ -0,0 +1,223 @@
|
||||
extends Node
|
||||
class_name NetworkWeapon
|
||||
|
||||
## Base class for creating responsive weapons, by spawning projectiles locally,
|
||||
## but keeping control on the server.
|
||||
|
||||
var _projectiles: Dictionary = {}
|
||||
var _projectile_data: Dictionary = {}
|
||||
var _reconcile_buffer: Array = []
|
||||
var _rng: RandomNumberGenerator = RandomNumberGenerator.new()
|
||||
var _fired_tick: int = -1
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_extras("NetworkWeapon")
|
||||
|
||||
func _ready():
|
||||
_rng.randomize()
|
||||
NetworkTime.before_tick_loop.connect(_before_tick_loop)
|
||||
|
||||
## Check whether this weapon can be fired.
|
||||
func can_fire() -> bool:
|
||||
return _can_fire()
|
||||
|
||||
## Try to fire the weapon and return the projectile.
|
||||
## [br][br]
|
||||
## Returns null if the weapon can't be fired.
|
||||
func fire() -> Node:
|
||||
if not can_fire():
|
||||
return null
|
||||
|
||||
var id: String = _generate_id()
|
||||
var projectile = _spawn()
|
||||
_save_projectile(projectile, id)
|
||||
var data = _projectile_data[id]
|
||||
|
||||
if not is_multiplayer_authority():
|
||||
_request_projectile.rpc_id(get_multiplayer_authority(), id, NetworkTime.tick, data)
|
||||
else:
|
||||
_accept_projectile.rpc(id, NetworkTime.tick, data)
|
||||
|
||||
_logger.debug("Calling after fire hook for %s", [projectile.name])
|
||||
_fired_tick = NetworkTime.tick
|
||||
_after_fire(projectile)
|
||||
|
||||
return projectile
|
||||
|
||||
## Get the tick when the weapon was fired.
|
||||
## [br][br]
|
||||
## Whenever a weapon gets fired, it takes time for that event to be transmitted
|
||||
## to the server. To account for this latency, the exact tick is sent along
|
||||
## with other data, so weapon implementations can compensate for the latency.
|
||||
## [br][br]
|
||||
## One way to use this is to manually simulate the projectile after it's
|
||||
## created:
|
||||
## [codeblock]
|
||||
## func _after_fire(projectile: Node3D):
|
||||
## last_fire = get_fired_tick()
|
||||
## sound.play()
|
||||
##
|
||||
## for t in range(get_fired_tick(), NetworkTime.tick):
|
||||
## if projectile.is_queued_for_deletion():
|
||||
## break
|
||||
## projectile._tick(NetworkTime.ticktime, t)
|
||||
## [/codeblock]
|
||||
func get_fired_tick() -> int:
|
||||
return _fired_tick
|
||||
|
||||
## Override this method with your own can fire logic.
|
||||
## [br][br]
|
||||
## This can be used to implement e.g. firing cooldowns and ammo checks.
|
||||
func _can_fire() -> bool:
|
||||
return false
|
||||
|
||||
## Override this method to check if a given peer can use this weapon.
|
||||
## [br][br]
|
||||
## Usually this should check if the weapon's owner is trying to fire it, but
|
||||
## for some special cases this can be some different logic, e.g. weapons that
|
||||
## can be used by any player on a given team.
|
||||
func _can_peer_use(peer_id: int) -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to run any logic needed after successfully firing the
|
||||
## weapon.
|
||||
## [br][br]
|
||||
## This can be used to e.g. reset the firing cooldown or deduct ammo.
|
||||
func _after_fire(projectile: Node):
|
||||
pass
|
||||
|
||||
## Override this method to spawn and initialize a projectile.
|
||||
## [br][br]
|
||||
## Make sure to return the projectile spawned!
|
||||
func _spawn() -> Node:
|
||||
return null
|
||||
|
||||
## Override this method to extract projectile data that should be synchronized
|
||||
## over the network.
|
||||
## [br][br]
|
||||
## This will be captured both locally and on the server, and will be used for
|
||||
## reconciliation.
|
||||
func _get_data(projectile: Node) -> Dictionary:
|
||||
return {}
|
||||
|
||||
## Override this method to apply projectile data that should be synchronized
|
||||
## over the network.
|
||||
## [br][br]
|
||||
## This is used in cases where some other client fires a weapon and the server
|
||||
## instructs us to spawn a projectile for it.
|
||||
func _apply_data(projectile: Node, data: Dictionary):
|
||||
pass
|
||||
|
||||
## Override this method to check if two projectile states can be reconciled.
|
||||
## [br][br]
|
||||
## This can be used to prevent cheating, for example by not allowing the client
|
||||
## to say it's firing from the other side of the map compared to its actual
|
||||
## position.
|
||||
## [br][br]
|
||||
## When this method returns false, the server will decline the projectile
|
||||
## request.
|
||||
func _is_reconcilable(projectile: Node, request_data: Dictionary, local_data: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
## Override this method to reconcile the initial local and remote projectile
|
||||
## state.
|
||||
## [br][br]
|
||||
## Let's say the projectile travels in a straight line from its origin, but we
|
||||
## receive a different origin from the server. In this reconciliation step,
|
||||
## the projectile's position can be adjusted to account for the different origin.
|
||||
## [br][br]
|
||||
## Unless the use case is niche, the best practice is to consider the server's
|
||||
## state as authorative.
|
||||
func _reconcile(projectile: Node, local_data: Dictionary, remote_data: Dictionary):
|
||||
pass
|
||||
|
||||
func _save_projectile(projectile: Node, id: String, data: Dictionary = {}):
|
||||
_projectiles[id] = projectile
|
||||
projectile.name += " " + id
|
||||
projectile.set_multiplayer_authority(get_multiplayer_authority())
|
||||
|
||||
if data.is_empty():
|
||||
data = _get_data(projectile)
|
||||
|
||||
_projectile_data[id] = data
|
||||
|
||||
func _before_tick_loop():
|
||||
# Reconcile projectiles
|
||||
for recon in _reconcile_buffer:
|
||||
var projectile = recon[0]
|
||||
var local_data = recon[1]
|
||||
var response_data = recon[2]
|
||||
var projectile_id = recon[3]
|
||||
|
||||
if is_instance_valid(projectile):
|
||||
_reconcile(projectile, local_data, response_data)
|
||||
else:
|
||||
_logger.warning("Projectile %s vanished by the time of reconciliation!", [projectile_id])
|
||||
|
||||
_reconcile_buffer.clear()
|
||||
|
||||
func _generate_id(length: int = 12, charset: String = "abcdefghijklmnopqrstuvwxyz0123456789") -> String:
|
||||
var result = ""
|
||||
|
||||
# Generate a random ID
|
||||
for i in range(length):
|
||||
var idx = _rng.randi_range(0, charset.length() - 1)
|
||||
result += charset[idx]
|
||||
|
||||
return result
|
||||
|
||||
@rpc("any_peer", "reliable", "call_remote")
|
||||
func _request_projectile(id: String, tick: int, request_data: Dictionary):
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
|
||||
# Reject if sender can't use this input
|
||||
_fired_tick = tick
|
||||
if not _can_peer_use(sender) or not _can_fire():
|
||||
_decline_projectile.rpc_id(sender, id)
|
||||
_logger.error("Projectile %s rejected! Peer %s can't use this weapon now", [id, sender])
|
||||
return
|
||||
|
||||
# Validate incoming data
|
||||
var projectile = _spawn()
|
||||
var local_data: Dictionary = _get_data(projectile)
|
||||
|
||||
if not _is_reconcilable(projectile, request_data, local_data):
|
||||
projectile.queue_free()
|
||||
_decline_projectile.rpc_id(sender, id)
|
||||
_logger.error("Projectile %s rejected! Can't reconcile states: [%s, %s]", [id, request_data, local_data])
|
||||
return
|
||||
|
||||
_save_projectile(projectile, id, local_data)
|
||||
_accept_projectile.rpc(id, tick, local_data)
|
||||
_after_fire(projectile)
|
||||
|
||||
@rpc("authority", "reliable", "call_local")
|
||||
func _accept_projectile(id: String, tick: int, response_data: Dictionary):
|
||||
if multiplayer.get_unique_id() == multiplayer.get_remote_sender_id():
|
||||
# Projectile is local, nothing to do
|
||||
return
|
||||
|
||||
_logger.info("Accepting projectile %s from %s", [id, multiplayer.get_remote_sender_id()])
|
||||
|
||||
if _projectiles.has(id):
|
||||
var projectile = _projectiles[id]
|
||||
var local_data = _projectile_data[id]
|
||||
_reconcile_buffer.push_back([projectile, local_data, response_data, id])
|
||||
else:
|
||||
_fired_tick = tick
|
||||
var projectile = _spawn()
|
||||
_apply_data(projectile, response_data)
|
||||
_projectile_data.erase(id)
|
||||
_save_projectile(projectile, id, response_data)
|
||||
_after_fire(projectile)
|
||||
|
||||
@rpc("authority", "reliable", "call_remote")
|
||||
func _decline_projectile(id: String):
|
||||
if not _projectiles.has(id):
|
||||
return
|
||||
|
||||
var p = _projectiles[id]
|
||||
if is_instance_valid(p):
|
||||
p.queue_free()
|
||||
|
||||
_projectiles.erase(id)
|
||||
_projectile_data.erase(id)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cill8uva6thl5
|
||||
@@ -0,0 +1,138 @@
|
||||
extends Node
|
||||
|
||||
# Settings
|
||||
var _is_enabled: bool = ProjectSettings.get_setting(&"netfox/extras/auto_tile_windows", false)
|
||||
var _is_borderless: bool = ProjectSettings.get_setting(&"netfox/extras/borderless", false)
|
||||
var _tile_screen: int = ProjectSettings.get_setting(&"netfox/extras/screen", 0)
|
||||
|
||||
# Hash the game name, so we always get a valid filename
|
||||
var _prefix: String = "netfox-window-tiler-%x" % [ProjectSettings.get_setting(&"application/config/name").hash()]
|
||||
|
||||
var _sid: String = "%x" % [hash(int(Time.get_unix_time_from_system() / 2.))]
|
||||
var _uid: String = "%d" % [Time.get_unix_time_from_system() * 1000_0000.]
|
||||
|
||||
static var _logger: NetfoxLogger = NetfoxLogger._for_extras("WindowTiler")
|
||||
|
||||
func _ready() -> void:
|
||||
# Running on a non-editor (export template) build
|
||||
if OS.has_feature("template"):
|
||||
return
|
||||
|
||||
# Running in headless mode
|
||||
if DisplayServer.get_name() == "headless":
|
||||
return
|
||||
|
||||
# Running in CI
|
||||
for env_var in ["CI", "NETFOX_CI"]:
|
||||
if OS.get_environment(env_var) != "":
|
||||
_logger.debug("Environment variable %s set, disabling", [env_var])
|
||||
return
|
||||
|
||||
# Cleanup in case some files were left
|
||||
_cleanup()
|
||||
|
||||
# Running embedded in editor
|
||||
if _is_embedded():
|
||||
return
|
||||
|
||||
# Don't tile if disabled
|
||||
if not _is_enabled:
|
||||
return
|
||||
|
||||
_logger.debug("Tiling with sid: %s, uid: %s", [_sid, _uid])
|
||||
|
||||
var err = _make_lock(_sid, _uid)
|
||||
if err != Error.OK:
|
||||
_logger.warning("Failed to create lock for tiling, reason: %s", [error_string(err)])
|
||||
return
|
||||
|
||||
# Search for locks, stop once no new locks are found
|
||||
var locks = []
|
||||
await get_tree().create_timer(0.25).timeout
|
||||
for i in range(20):
|
||||
await get_tree().create_timer(0.1).timeout
|
||||
var new_locks = _list_lock_ids()
|
||||
|
||||
if locks == new_locks:
|
||||
break
|
||||
|
||||
locks = new_locks
|
||||
|
||||
var tile_count = locks.size()
|
||||
var idx = locks.find(_uid)
|
||||
|
||||
_logger.debug("Tiling as idx %d / %d - %s in %s", [idx, tile_count, _uid, locks])
|
||||
_tile_window(idx, tile_count)
|
||||
|
||||
func _is_embedded() -> bool:
|
||||
if Engine.has_method("is_embedded_in_editor"):
|
||||
return Engine.call("is_embedded_in_editor")
|
||||
return false
|
||||
|
||||
func _make_lock(sid: String, uid: String) -> Error:
|
||||
var path = "%s/%s-%s-%s" % [OS.get_cache_dir(), _prefix, sid, uid]
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
|
||||
if file == null:
|
||||
return FileAccess.get_open_error()
|
||||
|
||||
file.close()
|
||||
return Error.OK
|
||||
|
||||
func _list_lock_ids() -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
var dir := DirAccess.open(OS.get_cache_dir())
|
||||
|
||||
if dir:
|
||||
for f in dir.get_files():
|
||||
if f.begins_with(_prefix):
|
||||
result.append(_get_uid(f))
|
||||
|
||||
return result
|
||||
|
||||
func _cleanup():
|
||||
var result: Array[String] = []
|
||||
var dir := DirAccess.open(OS.get_cache_dir())
|
||||
|
||||
if dir:
|
||||
for f in dir.get_files():
|
||||
if f.begins_with(_prefix) and _get_sid(f) != _sid:
|
||||
_logger.trace("Cleaned up lock: %s", [f])
|
||||
dir.remove(OS.get_cache_dir() + "/" + f)
|
||||
|
||||
func _get_sid(filename: String) -> String:
|
||||
return filename.substr(_prefix.length() + 1).get_slice("-", 0)
|
||||
|
||||
func _get_uid(filename: String) -> String:
|
||||
return filename.substr(_prefix.length() + 1).get_slice("-", 1)
|
||||
|
||||
func _tile_window(i: int, total: int) -> void:
|
||||
var screen = _tile_screen
|
||||
var screen_rect = DisplayServer.screen_get_usable_rect(screen)
|
||||
|
||||
var window: Window = get_tree().get_root()
|
||||
window.set_current_screen(screen)
|
||||
|
||||
if total == 1:
|
||||
DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_MAXIMIZED)
|
||||
return
|
||||
|
||||
window.borderless = _is_borderless
|
||||
|
||||
# Divide up the screen
|
||||
var windows_per_row = int(ceil(sqrt(total)))
|
||||
var windows_per_col = int(ceil(total / float(windows_per_row)))
|
||||
var window_size = Vector2(
|
||||
screen_rect.size.x / windows_per_row, screen_rect.size.y / windows_per_col
|
||||
)
|
||||
|
||||
window.set_size(window_size)
|
||||
|
||||
# Position of the window based on index.
|
||||
var row = i / windows_per_row
|
||||
var col = i % windows_per_row
|
||||
|
||||
var x = screen_rect.position.x + col * window_size.x
|
||||
var y = screen_rect.position.y + row * window_size.y
|
||||
|
||||
window.set_position(Vector2(x, y))
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmqjajhjgffm1
|
||||
@@ -0,0 +1,18 @@
|
||||
Copyright 2023 Gálffy Tamás
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,8 @@
|
||||
# netfox.internals
|
||||
|
||||
Shared utilities for [netfox] addons. Not intended for standalone usage.
|
||||
|
||||
Instead, check out the other addons in the [netfox] repository.
|
||||
|
||||
|
||||
[netfox]: https://github.com/foxssake/netfox
|
||||
@@ -0,0 +1,56 @@
|
||||
extends RefCounted
|
||||
class_name _BiMap
|
||||
|
||||
# Maps one-to-one associations in a bidirectional way
|
||||
|
||||
var _keys_to_values := {}
|
||||
var _values_to_keys := {}
|
||||
|
||||
func put(key: Variant, value: Variant) -> void:
|
||||
var old_value = _keys_to_values.get(key)
|
||||
var old_key = _values_to_keys.get(value)
|
||||
|
||||
if old_value != null: _values_to_keys.erase(old_value)
|
||||
if old_key != null: _keys_to_values.erase(old_key)
|
||||
|
||||
_keys_to_values[key] = value
|
||||
_values_to_keys[value] = key
|
||||
|
||||
func get_by_key(key: Variant) -> Variant:
|
||||
return _keys_to_values.get(key)
|
||||
|
||||
func get_by_value(value: Variant) -> Variant:
|
||||
return _values_to_keys.get(value)
|
||||
|
||||
func has_key(key: Variant) -> bool:
|
||||
return _keys_to_values.has(key)
|
||||
|
||||
func has_value(value: Variant) -> bool:
|
||||
return _values_to_keys.has(value)
|
||||
|
||||
func erase_key(key: Variant) -> bool:
|
||||
if not has_key(key):
|
||||
return false
|
||||
|
||||
var value = get_by_key(key)
|
||||
_values_to_keys.erase(value)
|
||||
_keys_to_values.erase(key)
|
||||
return true
|
||||
|
||||
func erase_value(value: Variant) -> bool:
|
||||
if not has_value(value):
|
||||
return false
|
||||
|
||||
var key = get_by_value(value)
|
||||
_values_to_keys.erase(value)
|
||||
_keys_to_values.erase(key)
|
||||
return true
|
||||
|
||||
func size() -> int:
|
||||
return _keys_to_values.size()
|
||||
|
||||
func keys() -> Array:
|
||||
return _keys_to_values.keys()
|
||||
|
||||
func values() -> Array:
|
||||
return _values_to_keys.keys()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b826s26d4ud5j
|
||||
@@ -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,44 @@
|
||||
extends Object
|
||||
class_name _NetfoxEditorUtils
|
||||
|
||||
static func gather_properties(root: Node, callback_name: String, handler: Callable) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
|
||||
var nodes: Array[Node] = root.find_children("*")
|
||||
nodes.push_front(root)
|
||||
for node in nodes:
|
||||
if not node.has_method(callback_name):
|
||||
continue
|
||||
|
||||
var readable_node_name := "\"%s\" (\"%s\")" % [node.name, root.get_path_to(node)]
|
||||
if node.get(callback_name) == null:
|
||||
result.push_back("Can't grab method \"%s\" from node %s! Is it a @tool?" % [callback_name, readable_node_name])
|
||||
continue
|
||||
|
||||
var props = node.get(callback_name).call()
|
||||
if not props is Array:
|
||||
result.push_back("Node %s didn't return an array on calling \"%s\"" % [readable_node_name, callback_name])
|
||||
continue
|
||||
|
||||
for prop in props:
|
||||
if prop is String:
|
||||
# Property is a string, meaning property path relative to node
|
||||
handler.call(node, prop)
|
||||
elif prop is Array and prop.size() >= 2:
|
||||
# Property is a node-property tuple
|
||||
var prop_node: Node = null
|
||||
|
||||
# Node can be a String, NodePath, or an actual Node
|
||||
if prop[0] is String or prop[0] is NodePath:
|
||||
prop_node = node.get_node(prop[0])
|
||||
elif prop[0] is Node:
|
||||
prop_node = prop[0]
|
||||
else:
|
||||
result.push_back("Node %s specified invalid node in \"%s\": %s" % [readable_node_name, callback_name, prop])
|
||||
continue
|
||||
|
||||
handler.call(prop_node, prop[1])
|
||||
else:
|
||||
result.push_back("Node %s specified invalid property in \"%s\": %s" % [readable_node_name, callback_name, prop])
|
||||
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://0ohd4n5yjilb
|
||||
@@ -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
|
||||
@@ -0,0 +1,134 @@
|
||||
extends RefCounted
|
||||
class_name _HistoryBuffer
|
||||
|
||||
# Maps ticks (int) to arbitrary data, stored in a sliding ring buffer
|
||||
|
||||
var _capacity := 64
|
||||
var _buffer := []
|
||||
var _previous := []
|
||||
|
||||
var _tail := 0
|
||||
var _head := 0
|
||||
|
||||
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 _init(capacity: int = 64):
|
||||
_capacity = capacity
|
||||
_buffer.resize(_capacity)
|
||||
_previous.resize(_capacity)
|
||||
|
||||
func duplicate(deep: bool = false) -> _HistoryBuffer:
|
||||
var buffer := _HistoryBuffer.new(_capacity)
|
||||
|
||||
buffer._buffer = _buffer.duplicate(deep)
|
||||
buffer._previous = _previous.duplicate()
|
||||
buffer._tail = _tail
|
||||
buffer._head = _head
|
||||
|
||||
return buffer
|
||||
|
||||
func push(value: Variant) -> void:
|
||||
_buffer[_head % _capacity] = value
|
||||
_previous[_head % _capacity] = _head
|
||||
_head += 1
|
||||
_tail += maxi(0, size() - capacity())
|
||||
|
||||
func pop() -> Variant:
|
||||
assert(is_not_empty(), "History buffer is empty!")
|
||||
|
||||
var value = _buffer[_tail % _capacity]
|
||||
_tail += 1
|
||||
return value
|
||||
|
||||
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
|
||||
|
||||
_previous.fill(null)
|
||||
_buffer.fill(null)
|
||||
|
||||
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 _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 size() == 0
|
||||
|
||||
func is_not_empty() -> bool:
|
||||
return not is_empty()
|
||||
@@ -0,0 +1 @@
|
||||
uid://btuvvlw3jkx2e
|
||||
@@ -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
|
||||
@@ -0,0 +1,208 @@
|
||||
extends RefCounted
|
||||
class_name NetfoxLogger
|
||||
|
||||
## Logger implementation for use with netfox
|
||||
##
|
||||
## NetfoxLoggers implement distinct log levels. These can be used to filter
|
||||
## which messages are actually emitted. All messages are output using [method
|
||||
## @GlobalScope.print]. Warnings and errors are also pushed to the debug panel,
|
||||
## using [method @GlobalScope.push_warning] and [method @GlobalScope.push_error]
|
||||
## respectively, if [member push_to_debugger] is enabled.
|
||||
## [br][br]
|
||||
## Every logger has a name, and belongs to a module. Logging level can be
|
||||
## overridden per module, using [member module_log_level].
|
||||
## [br][br]
|
||||
## Loggers also support tags. Tags can be used to provide extra pieces of
|
||||
## information that are logged with each message. Some tags are provided by
|
||||
## netfox. Additional tags can be added using [method register_tag].
|
||||
##
|
||||
## @tutorial(Logging Guide): https://foxssake.github.io/netfox/latest/netfox/guides/logging/
|
||||
|
||||
|
||||
enum {
|
||||
LOG_ALL, ## Filter level to log every message
|
||||
LOG_TRACE, ## Trace logs, the most verbose level
|
||||
LOG_DEBUG, ## Debug logs
|
||||
LOG_INFO, ## Info logs
|
||||
LOG_WARN, ## Warnings
|
||||
LOG_ERROR, ## Errors
|
||||
LOG_NONE ## Filter level to log no messages
|
||||
}
|
||||
|
||||
## Default log level to fall back on, if not configured
|
||||
const DEFAULT_LOG_LEVEL := LOG_DEBUG
|
||||
|
||||
const _LEVEL_PREFIXES: Array[String] = [
|
||||
"",
|
||||
"TRC",
|
||||
"DBG",
|
||||
"INF",
|
||||
"WRN",
|
||||
"ERR",
|
||||
""
|
||||
]
|
||||
|
||||
## Global logging level, used by all loggers
|
||||
static var log_level: int
|
||||
|
||||
## Per-module logging level, used only by loggers belonging to the given module
|
||||
## [br][br]
|
||||
## This is a dictionary that associates module names ( strings ) to log levels
|
||||
## ( int, e.g. [constant LOG_DEBUG] ).
|
||||
static var module_log_level: Dictionary
|
||||
|
||||
## Set to true to enable calling [@GlobalScope.push_warning] and
|
||||
## [@GlobalScope.push_error]
|
||||
static var push_to_debugger := true
|
||||
|
||||
static var _tags: Dictionary = {}
|
||||
static var _ordered_tags: Array[Callable] = []
|
||||
|
||||
## Logger module
|
||||
var module: String
|
||||
## Logger name
|
||||
var name: String
|
||||
|
||||
|
||||
## Register a tag
|
||||
## [br][br]
|
||||
## Tags are callables that provide pieces of context, included in all log
|
||||
## messges. The [param tag] callable must return a string.
|
||||
static func register_tag(tag: Callable, priority: int = 0) -> void:
|
||||
# Save tag
|
||||
if not _tags.has(priority):
|
||||
_tags[priority] = [tag]
|
||||
else:
|
||||
_tags[priority].push_back(tag)
|
||||
|
||||
# Recalculate tag order
|
||||
_ordered_tags.clear()
|
||||
|
||||
var prio_groups = _tags.keys()
|
||||
prio_groups.sort()
|
||||
|
||||
for prio_group in prio_groups:
|
||||
var tag_group = _tags[prio_group]
|
||||
_ordered_tags.append_array(tag_group)
|
||||
|
||||
## Free an already registered tag
|
||||
static func free_tag(tag: Callable) -> void:
|
||||
for priority in _tags.keys():
|
||||
var priority_group := _tags[priority] as Array
|
||||
priority_group.erase(tag)
|
||||
|
||||
# NOTE: Arrays are passed as reference, no need to re-assign after modifying
|
||||
if priority_group.is_empty():
|
||||
_tags.erase(priority)
|
||||
|
||||
_ordered_tags.erase(tag)
|
||||
|
||||
static func _static_init():
|
||||
log_level = ProjectSettings.get_setting(&"netfox/logging/log_level", DEFAULT_LOG_LEVEL)
|
||||
module_log_level = {
|
||||
"netfox": ProjectSettings.get_setting(&"netfox/logging/netfox_log_level", DEFAULT_LOG_LEVEL),
|
||||
"netfox.noray": ProjectSettings.get_setting(&"netfox/logging/netfox_noray_log_level", DEFAULT_LOG_LEVEL),
|
||||
"netfox.extras": ProjectSettings.get_setting(&"netfox/logging/netfox_extras_log_level", DEFAULT_LOG_LEVEL)
|
||||
}
|
||||
|
||||
static func _for_netfox(p_name: String) -> NetfoxLogger:
|
||||
return NetfoxLogger.new("netfox", p_name)
|
||||
|
||||
static func _for_noray(p_name: String) -> NetfoxLogger:
|
||||
return NetfoxLogger.new("netfox.noray", p_name)
|
||||
|
||||
static func _for_extras(p_name: String) -> NetfoxLogger:
|
||||
return NetfoxLogger.new("netfox.extras", p_name)
|
||||
|
||||
static func _make_setting(name: String) -> Dictionary:
|
||||
return {
|
||||
"name": name,
|
||||
"value": DEFAULT_LOG_LEVEL,
|
||||
"type": TYPE_INT,
|
||||
"hint": PROPERTY_HINT_ENUM,
|
||||
"hint_string": "All,Trace,Debug,Info,Warning,Error,None"
|
||||
}
|
||||
|
||||
|
||||
func _init(p_module: String, p_name: String):
|
||||
module = p_module
|
||||
name = p_name
|
||||
|
||||
## Log a trace message
|
||||
## [br][br]
|
||||
## Traces are the most verbose, usually used for drilling down into very niche
|
||||
## bugs.
|
||||
func trace(text: String, values: Array = []):
|
||||
_log_text(text, values, LOG_TRACE)
|
||||
|
||||
## Log a debug message
|
||||
## [br][br]
|
||||
## Debug messages are verbose, usually used to reconstruct and investigate bugs.
|
||||
func debug(text: String, values: Array = []):
|
||||
_log_text(text, values, LOG_DEBUG)
|
||||
|
||||
## Log an info message
|
||||
## [br][br]
|
||||
## Info messages provide general notifications about application events.
|
||||
func info(text: String, values: Array = []):
|
||||
_log_text(text, values, LOG_INFO)
|
||||
|
||||
## Log a warning message
|
||||
## [br][br]
|
||||
## This is also forwarded to [method @GlobalScope.push_warning], if enabled with
|
||||
## [member push_to_debugger]. Warning messages usually indicate that something
|
||||
## has gone wrong, but is recoverable.
|
||||
func warning(text: String, values: Array = []):
|
||||
if _check_log_level(LOG_WARN):
|
||||
var formatted_text = _format_text(text, values, LOG_WARN)
|
||||
if push_to_debugger:
|
||||
push_warning(formatted_text)
|
||||
|
||||
# Print so it shows up in the Output panel too
|
||||
print(formatted_text)
|
||||
|
||||
## Log an error message
|
||||
## [br][br]
|
||||
## This is also forwarded to [method @GlobalScope.push_error], if enabled with
|
||||
## [member push_to_debugger]. Error messages usually indicate an issue that
|
||||
## can't be recovered from.
|
||||
func error(text: String, values: Array = []):
|
||||
if _check_log_level(LOG_ERROR):
|
||||
var formatted_text = _format_text(text, values, LOG_ERROR)
|
||||
if push_to_debugger:
|
||||
push_error(formatted_text)
|
||||
|
||||
# Print so it shows up in the Output panel too
|
||||
print(formatted_text)
|
||||
|
||||
func _check_log_level(level: int) -> bool:
|
||||
var cmp_level = log_level
|
||||
if level < cmp_level:
|
||||
return false
|
||||
|
||||
if module_log_level.has(module):
|
||||
var module_level = module_log_level.get(module)
|
||||
return level >= module_level
|
||||
|
||||
return true
|
||||
|
||||
func _format_text(text: String, values: Array, level: int) -> String:
|
||||
level = clampi(level, LOG_TRACE, LOG_ERROR)
|
||||
|
||||
var result := PackedStringArray()
|
||||
|
||||
result.append("[%s]" % [_LEVEL_PREFIXES[level]])
|
||||
for tag in _ordered_tags:
|
||||
result.append("[%s]" % [tag.call()])
|
||||
result.append("[%s::%s] " % [module, name])
|
||||
|
||||
if values.is_empty():
|
||||
result.append(text)
|
||||
else:
|
||||
result.append(text % values)
|
||||
|
||||
return "".join(result)
|
||||
|
||||
func _log_text(text: String, values: Array, level: int):
|
||||
if _check_log_level(level):
|
||||
print(_format_text(text, values, level))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dp4rakv0drj1f
|
||||
@@ -0,0 +1,7 @@
|
||||
[plugin]
|
||||
|
||||
name="netfox.internals"
|
||||
description="Shared internals for netfox addons"
|
||||
author="Tamas Galffy and contributors"
|
||||
version="1.40.2"
|
||||
script="plugin.gd"
|
||||
@@ -0,0 +1,34 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
var SETTINGS = [
|
||||
NetfoxLogger._make_setting("netfox/logging/log_level")
|
||||
]
|
||||
|
||||
func _enter_tree():
|
||||
for setting in SETTINGS:
|
||||
add_setting(setting)
|
||||
|
||||
func _exit_tree():
|
||||
if ProjectSettings.get_setting("netfox/general/clear_settings", false):
|
||||
for setting in SETTINGS:
|
||||
remove_setting(setting)
|
||||
|
||||
func add_setting(setting: Dictionary):
|
||||
if ProjectSettings.has_setting(setting.name):
|
||||
return
|
||||
|
||||
ProjectSettings.set_setting(setting.name, setting.value)
|
||||
ProjectSettings.set_initial_value(setting.name, setting.value)
|
||||
ProjectSettings.add_property_info({
|
||||
"name": setting.get("name"),
|
||||
"type": setting.get("type"),
|
||||
"hint": setting.get("hint", PROPERTY_HINT_NONE),
|
||||
"hint_string": setting.get("hint_string", "")
|
||||
})
|
||||
|
||||
func remove_setting(setting: Dictionary):
|
||||
if not ProjectSettings.has_setting(setting.name):
|
||||
return
|
||||
|
||||
ProjectSettings.clear(setting.name)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvhj7f66i6yl4
|
||||
@@ -0,0 +1,34 @@
|
||||
extends RefCounted
|
||||
class_name _RingBuffer
|
||||
|
||||
var _data: Array
|
||||
var _capacity: int
|
||||
var _size: int = 0
|
||||
var _head: int = 0
|
||||
|
||||
func _init(p_capacity: int):
|
||||
_capacity = p_capacity
|
||||
_data = []
|
||||
_data.resize(p_capacity)
|
||||
|
||||
func push(item):
|
||||
_data[_head] = item
|
||||
|
||||
_size += 1
|
||||
_head = (_head + 1) % _capacity
|
||||
|
||||
func get_data() -> Array:
|
||||
if _size < _capacity:
|
||||
return _data.slice(0, _size)
|
||||
else:
|
||||
return _data
|
||||
|
||||
func size() -> int:
|
||||
return _size
|
||||
|
||||
func is_empty() -> bool:
|
||||
return _size == 0
|
||||
|
||||
func clear():
|
||||
_size = 0
|
||||
_head = 0
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmfbs3cx33lkw
|
||||
@@ -0,0 +1,72 @@
|
||||
extends RefCounted
|
||||
class_name _Set
|
||||
|
||||
var _data: Dictionary = {}
|
||||
var _iterator_idx: int = -1
|
||||
|
||||
static func of(items: Array) -> _Set:
|
||||
var result := _Set.new()
|
||||
for item in items:
|
||||
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
|
||||
|
||||
func has(value) -> bool:
|
||||
return _data.has(value)
|
||||
|
||||
func size() -> int:
|
||||
return _data.size()
|
||||
|
||||
func is_empty() -> bool:
|
||||
return _data.is_empty()
|
||||
|
||||
func erase(value):
|
||||
return _data.erase(value)
|
||||
|
||||
func clear():
|
||||
_data.clear()
|
||||
|
||||
func values() -> Array:
|
||||
return _data.keys()
|
||||
|
||||
func min():
|
||||
return _data.keys().min()
|
||||
|
||||
func max():
|
||||
return _data.keys().max()
|
||||
|
||||
func equals(other) -> bool:
|
||||
if not other or not other is _Set:
|
||||
return false
|
||||
|
||||
return values() == other.values()
|
||||
|
||||
func _to_string():
|
||||
return "Set" + str(values())
|
||||
|
||||
func _to_vest():
|
||||
return _data.keys()
|
||||
|
||||
func _iter_init(arg) -> bool:
|
||||
_iterator_idx = 0
|
||||
return _can_iterate()
|
||||
|
||||
func _iter_next(arg) -> bool:
|
||||
_iterator_idx += 1
|
||||
return _can_iterate()
|
||||
|
||||
func _iter_get(arg):
|
||||
return _data.keys()[_iterator_idx]
|
||||
|
||||
func _can_iterate() -> bool:
|
||||
if _data.is_empty() or _iterator_idx >= _data.size():
|
||||
_iterator_idx = -1
|
||||
return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjvmp7eed1aj0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user