chore: initial project state (godot-mcp addon, docs)
This commit is contained in:
39
docs/STATUS.md
Normal file
39
docs/STATUS.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Ruf der Pilze — Projektstatus
|
||||
|
||||
Zuletzt aktualisiert: 2026-04-13
|
||||
|
||||
---
|
||||
|
||||
## Aktueller Stand
|
||||
|
||||
### ✅ Abgeschlossen
|
||||
- MCP-Addon eingerichtet (`godot-mcp`, Claude Code verbunden)
|
||||
- Projektstruktur angelegt (`ruf-der-pilze/`)
|
||||
- CLAUDE.md mit vollständiger Spielkonzept-Dokumentation
|
||||
|
||||
### 🔄 In Arbeit
|
||||
— (nichts aktiv)
|
||||
|
||||
### ⏳ Als nächstes
|
||||
- **Multiplayer Grundgerüst** → Plan: `docs/plans/multiplayer-grundgeruest.md`
|
||||
- Server-Szene (headless-fähig)
|
||||
- Clients verbinden
|
||||
- Basis-RPC testen
|
||||
|
||||
---
|
||||
|
||||
## Entwicklungs-Reihenfolge (gesamt)
|
||||
|
||||
1. ✅ MCP eingerichtet
|
||||
2. ⏳ Multiplayer Grundgerüst (Server, Clients verbinden, rpc testen)
|
||||
3. ⏳ Refektorium — asymmetrische Wahrnehmung (erster Raum)
|
||||
4. ⏳ DM Regiepult Basics — Overlay-Toggle
|
||||
5. ⏳ Alle Räume aufbauen
|
||||
6. ⏳ Polish — Audio, Nebel, Licht, Würfel-UI
|
||||
|
||||
---
|
||||
|
||||
## Offene Entscheidungen
|
||||
|
||||
- Transport: ENet oder WebSocket? (WebSocket = Browser-kompatibel, ENet = performanter)
|
||||
- VPS bereits vorhanden oder noch einzurichten?
|
||||
0
docs/plans/.gitkeep
Normal file
0
docs/plans/.gitkeep
Normal file
184
docs/plans/2026-04-13-multiplayer-grundgeruest-design.md
Normal file
184
docs/plans/2026-04-13-multiplayer-grundgeruest-design.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Multiplayer Grundgerüst — Design Spec
|
||||
|
||||
**Datum:** 2026-04-13
|
||||
**Status:** Abgenommen
|
||||
**Projekt:** Ruf der Pilze (`ruf-der-pilze/`)
|
||||
|
||||
---
|
||||
|
||||
## Kontext
|
||||
|
||||
Das Spiel benötigt eine Multiplayer-Basis bevor irgendeine Spiellogik gebaut werden kann. Dieses Grundgerüst schafft die Verbindungsschicht: Server startet, Clients verbinden, RPCs funktionieren. Alles Weitere (Räume, Overlays, DM-Regiepult) baut darauf auf.
|
||||
|
||||
---
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
| Entscheidung | Wahl | Begründung |
|
||||
|---|---|---|
|
||||
| Transport | ENet | Native Godot UDP, geringste Latenz, kein Browser-Support benötigt |
|
||||
| Dev-Server | Lokal headless | Kein VPS-Overhead während Entwicklung |
|
||||
| Max. Verbindungen | 8 | 5 Spieler + 1 DM + 2 Reserve |
|
||||
| Architektur | Eine Hauptszene + Autoload | Godot-idiomatisch |
|
||||
| Port | 4242 | Projektstandard |
|
||||
| Server-Erkennung | `--server` CLI-Argument | `OS.has_feature("dedicated_server")` gilt nur für Server-Exports, nicht für headless Dev-Runs |
|
||||
|
||||
---
|
||||
|
||||
## Dateistruktur
|
||||
|
||||
```
|
||||
ruf-der-pilze/
|
||||
├── scenes/
|
||||
│ └── main.tscn ← Root-Szene (Node)
|
||||
├── scripts/
|
||||
│ ├── main.gd ← Modus-Erkennung + Bootstrap
|
||||
│ └── network_manager.gd ← Autoload: Peer-Verwaltung, Signale
|
||||
└── project.godot ← NetworkManager als Autoload registriert
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
### NetworkManager (Autoload)
|
||||
|
||||
Singleton, der den gesamten Multiplayer-Peer hält. Szenen greifen über `NetworkManager` auf Verbindungsstatus und peer_ids zu.
|
||||
|
||||
**Verantwortlichkeiten:**
|
||||
- ENetMultiplayerPeer erstellen (Server oder Client)
|
||||
- Signale weiterleiten: `peer_connected(id)`, `peer_disconnected(id)`, `connected_to_server()`, `connection_failed()`
|
||||
- Eigene peer_id bereitstellen: `NetworkManager.my_id` (wird bei Verbindung gesetzt)
|
||||
- Liste aller verbundenen peers: `NetworkManager.peers` (Dictionary `id → {}`)
|
||||
|
||||
**Signale:**
|
||||
```gdscript
|
||||
signal peer_connected(id: int)
|
||||
signal peer_disconnected(id: int)
|
||||
signal connection_failed()
|
||||
signal connected_to_server()
|
||||
```
|
||||
|
||||
### main.gd — Modus-Erkennung
|
||||
|
||||
```gdscript
|
||||
func _ready() -> void:
|
||||
# OS.has_feature("dedicated_server") gilt nur bei Server-Export-Template.
|
||||
# Für lokale Dev-Runs: --server als CLI-Argument übergeben.
|
||||
var is_server := OS.has_feature("dedicated_server") \
|
||||
or "--server" in OS.get_cmdline_user_args()
|
||||
|
||||
if is_server:
|
||||
NetworkManager.start_server(4242, 8)
|
||||
else:
|
||||
NetworkManager.join_server("127.0.0.1", 4242)
|
||||
```
|
||||
|
||||
### Server-Initialisierung
|
||||
|
||||
```gdscript
|
||||
func start_server(port: int, max_clients: int) -> void:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, max_clients)
|
||||
if err != OK:
|
||||
push_error("[Server] Port %d konnte nicht gebunden werden: %s" % [port, error_string(err)])
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
print("[Server] Gestartet auf Port %d" % port)
|
||||
|
||||
func _on_peer_connected(id: int) -> void:
|
||||
peers[id] = {}
|
||||
peer_connected.emit(id)
|
||||
print("[Server] Peer verbunden: %d" % id)
|
||||
welcome.rpc_id(id, id) # nur an diesen Client
|
||||
|
||||
func _on_peer_disconnected(id: int) -> void:
|
||||
peers.erase(id)
|
||||
peer_disconnected.emit(id)
|
||||
print("[Server] Peer getrennt: %d" % id)
|
||||
```
|
||||
|
||||
### Client-Verbindung
|
||||
|
||||
```gdscript
|
||||
func join_server(ip: String, port: int) -> void:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(ip, port)
|
||||
if err != OK:
|
||||
push_error("[Client] Verbindung zu %s:%d fehlgeschlagen: %s" % [ip, port, error_string(err)])
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
||||
multiplayer.connection_failed.connect(_on_connection_failed)
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
my_id = multiplayer.get_unique_id()
|
||||
connected_to_server.emit()
|
||||
print("[Client] Verbunden. Meine peer_id: %d" % my_id)
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
connection_failed.emit()
|
||||
push_error("[Client] Verbindung fehlgeschlagen")
|
||||
```
|
||||
|
||||
### RPC-Smoke-Test
|
||||
|
||||
Server schickt nach jeder neuen Verbindung ein `welcome`-RPC **nur an diesen Client**:
|
||||
|
||||
```gdscript
|
||||
# Hinweis: call_remote bedeutet der Server führt diese Funktion NICHT lokal aus.
|
||||
# Sie wird ausschließlich auf dem Ziel-Client ausgeführt.
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func welcome(peer_id: int) -> void:
|
||||
print("[Client] Willkommen, meine peer_id ist: %d" % peer_id)
|
||||
```
|
||||
|
||||
Erwartetes Verhalten:
|
||||
- **Server-Terminal:** zeigt nur `[Server] Peer verbunden: 123456`
|
||||
- **Client-Output:** zeigt `[Client] Verbunden. Meine peer_id: 123456` und `[Client] Willkommen, meine peer_id ist: 123456`
|
||||
|
||||
---
|
||||
|
||||
## Verifikation
|
||||
|
||||
### Schritt 1 — Server starten (headless)
|
||||
```bash
|
||||
cd ruf-der-pilze/
|
||||
godot --headless -- --server
|
||||
# Erwartete Ausgabe: [Server] Gestartet auf Port 4242
|
||||
```
|
||||
|
||||
> **Hinweis:** `--` trennt Godot-Argumente von User-Argumenten. `OS.get_cmdline_user_args()` gibt `["--server"]` zurück.
|
||||
|
||||
### Schritt 2 — Client verbinden (Godot Editor, F5)
|
||||
```
|
||||
# Server-Terminal zeigt:
|
||||
[Server] Peer verbunden: 123456
|
||||
|
||||
# Client-Ausgabe (Godot Output-Panel):
|
||||
[Client] Verbunden. Meine peer_id: 123456
|
||||
[Client] Willkommen, meine peer_id ist: 123456
|
||||
```
|
||||
|
||||
### Schritt 3 — Zweiten Client testen
|
||||
Zweiten Godot-Prozess starten → Server zeigt beide peer_ids, beide Clients erhalten ihre individuelle Welcome-Nachricht mit korrekter peer_id.
|
||||
|
||||
### Schritt 4 — Disconnect testen
|
||||
Client schließen → Server-Terminal zeigt `[Server] Peer getrennt: 123456`, `peers` Dictionary hat den Eintrag entfernt.
|
||||
|
||||
---
|
||||
|
||||
## Was dieses Grundgerüst NICHT enthält
|
||||
|
||||
- Spieler-Rollen (Spieler vs. DM) — kommt im nächsten Schritt
|
||||
- Authentifizierung / Lobby-Logik
|
||||
- Reconnect-Handling
|
||||
- Irgendwelche Spiellogik
|
||||
|
||||
---
|
||||
|
||||
## Nächster Schritt
|
||||
|
||||
Aufbauend auf diesem Grundgerüst: **Lobby + Rollen** (Spieler registrieren sich mit Name und Rolle, DM bekommt Sonderrechte).
|
||||
338
docs/plans/2026-04-13-multiplayer-grundgeruest-plan.md
Normal file
338
docs/plans/2026-04-13-multiplayer-grundgeruest-plan.md
Normal file
@@ -0,0 +1,338 @@
|
||||
# Multiplayer Grundgerüst — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Server und Client verbinden sich via ENet, der Server schickt bei Verbindung ein welcome-RPC an den Client — verifiziert durch Terminal-Output.
|
||||
|
||||
**Architecture:** Eine einzige `main.tscn` erkennt per `OS.has_feature("dedicated_server") or "--server" in OS.get_cmdline_user_args()` ob sie Server oder Client ist und delegiert an den `NetworkManager`-Autoload. Der `NetworkManager` hält den ENet-Peer und leitet alle Verbindungs-Signale weiter.
|
||||
|
||||
**Tech Stack:** Godot 4.6.2, GDScript, ENetMultiplayerPeer, Godot MultiplayerAPI
|
||||
|
||||
**Spec:** `docs/plans/2026-04-13-multiplayer-grundgeruest-design.md`
|
||||
|
||||
**Arbeitsverzeichnis:** Alle Befehle werden aus `ruf-der-pilze/` ausgeführt, sofern nicht anders angegeben.
|
||||
|
||||
---
|
||||
|
||||
## Dateiübersicht
|
||||
|
||||
| Datei | Aktion | Verantwortlichkeit |
|
||||
|---|---|---|
|
||||
| `scripts/network_manager.gd` | Erstellen | Autoload: ENet-Peer, Signale, peer_id-Verwaltung |
|
||||
| `scripts/main.gd` | Erstellen | Modus-Erkennung (Server/Client), Bootstrap |
|
||||
| `scenes/main.tscn` | Erstellen | Root-Szene (Node + main.gd) |
|
||||
| `project.godot` | Modifizieren | NetworkManager als Autoload, main.tscn als Hauptszene |
|
||||
|
||||
---
|
||||
|
||||
### Task 0: Voraussetzungen
|
||||
|
||||
**Files:** keine
|
||||
|
||||
- [ ] **Step 1: Godot-Binary prüfen**
|
||||
|
||||
```bash
|
||||
which godot || which godot4
|
||||
```
|
||||
|
||||
Merke dir den Namen (wahrscheinlich `godot4` auf Arch Linux). Ersetze `godot` in allen folgenden Schritten durch den tatsächlichen Binary-Namen.
|
||||
|
||||
- [ ] **Step 2: Verzeichnisse anlegen**
|
||||
|
||||
```bash
|
||||
# aus ruf-der-pilze/
|
||||
mkdir -p scripts scenes
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Git initialisieren (falls noch nicht vorhanden)**
|
||||
|
||||
Das Git-Repository soll den gesamten `DnD_Anna_OneShot/`-Ordner abdecken, damit `docs/` und `ruf-der-pilze/` gemeinsam versioniert werden.
|
||||
|
||||
```bash
|
||||
# aus DnD_Anna_OneShot/ (eine Ebene höher als ruf-der-pilze/)
|
||||
cd /home/mpuchstein/Dev/Godot/DnD_Anna_OneShot
|
||||
git status 2>/dev/null || git init
|
||||
```
|
||||
|
||||
Falls `git init` ausgeführt wurde: einmalig committen was schon da ist:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "chore: initial project state (godot-mcp addon, docs)"
|
||||
```
|
||||
|
||||
Danach für alle weiteren Schritte wieder in `ruf-der-pilze/` wechseln:
|
||||
|
||||
```bash
|
||||
cd ruf-der-pilze/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: NetworkManager-Autoload schreiben
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/network_manager.gd`
|
||||
|
||||
- [ ] **Step 1: Datei erstellen**
|
||||
|
||||
Erstelle `ruf-der-pilze/scripts/network_manager.gd`:
|
||||
|
||||
```gdscript
|
||||
extends Node
|
||||
|
||||
signal peer_connected(id: int)
|
||||
signal peer_disconnected(id: int)
|
||||
signal connected_to_server()
|
||||
signal connection_failed()
|
||||
|
||||
var peers: Dictionary = {}
|
||||
var my_id: int = 0
|
||||
|
||||
|
||||
func start_server(port: int, max_clients: int) -> void:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_server(port, max_clients)
|
||||
if err != OK:
|
||||
push_error("[Server] Port %d konnte nicht gebunden werden: %s" % [port, error_string(err)])
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
print("[Server] Gestartet auf Port %d" % port)
|
||||
|
||||
|
||||
func join_server(ip: String, port: int) -> void:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var err := peer.create_client(ip, port)
|
||||
if err != OK:
|
||||
push_error("[Client] Verbindung zu %s:%d fehlgeschlagen: %s" % [ip, port, error_string(err)])
|
||||
return
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.connected_to_server.connect(_on_connected_to_server)
|
||||
multiplayer.connection_failed.connect(_on_connection_failed)
|
||||
|
||||
|
||||
func _on_peer_connected(id: int) -> void:
|
||||
peers[id] = {}
|
||||
peer_connected.emit(id)
|
||||
print("[Server] Peer verbunden: %d" % id)
|
||||
welcome.rpc_id(id, id)
|
||||
|
||||
|
||||
func _on_peer_disconnected(id: int) -> void:
|
||||
peers.erase(id)
|
||||
peer_disconnected.emit(id)
|
||||
print("[Server] Peer getrennt: %d" % id)
|
||||
|
||||
|
||||
func _on_connected_to_server() -> void:
|
||||
my_id = multiplayer.get_unique_id()
|
||||
connected_to_server.emit()
|
||||
print("[Client] Verbunden. Meine peer_id: %d" % my_id)
|
||||
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
connection_failed.emit()
|
||||
push_error("[Client] Verbindung fehlgeschlagen")
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable")
|
||||
func welcome(peer_id: int) -> void:
|
||||
# call_remote: wird NUR auf dem Ziel-Client ausgeführt, nicht auf dem Server.
|
||||
print("[Client] Willkommen, meine peer_id ist: %d" % peer_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Syntax im Editor prüfen**
|
||||
|
||||
`scripts/network_manager.gd` in Godot öffnen → Script-Editor zeigt keine roten Fehler-Markierungen. Kein Parse-Error im Output-Panel.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
# aus ruf-der-pilze/
|
||||
git add scripts/network_manager.gd
|
||||
git commit -m "net: add NetworkManager autoload with ENet server/client and welcome RPC"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Autoload in project.godot registrieren
|
||||
|
||||
**Files:**
|
||||
- Modify: `project.godot`
|
||||
|
||||
- [ ] **Step 1: Autoload über Editor registrieren**
|
||||
|
||||
In Godot: **Project → Project Settings → Autoload**
|
||||
- Pfad: `res://scripts/network_manager.gd`
|
||||
- Name: `NetworkManager`
|
||||
- Global Variable: ✅ aktiviert
|
||||
- Auf "Add" klicken → "NetworkManager" erscheint in der Liste
|
||||
|
||||
`project.godot` enthält danach:
|
||||
```ini
|
||||
[autoload]
|
||||
NetworkManager="*res://scripts/network_manager.gd"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verifizieren**
|
||||
|
||||
Godot-Editor schließen und neu starten (Projekt erneut öffnen). Prüfen ob im Output-Panel beim Start Fehler erscheinen. Kein "Could not load script" oder "NetworkManager not found" → Autoload korrekt registriert.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
# aus ruf-der-pilze/
|
||||
git add project.godot
|
||||
git commit -m "net: register NetworkManager as autoload"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: main.gd schreiben
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/main.gd`
|
||||
|
||||
- [ ] **Step 1: Datei erstellen**
|
||||
|
||||
Erstelle `ruf-der-pilze/scripts/main.gd`:
|
||||
|
||||
```gdscript
|
||||
extends Node
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# OS.has_feature("dedicated_server") gilt nur bei Server-Export-Template.
|
||||
# Für lokale Dev-Runs: --server als User-Argument übergeben (nach -- in CLI).
|
||||
var is_server := OS.has_feature("dedicated_server") \
|
||||
or "--server" in OS.get_cmdline_user_args()
|
||||
|
||||
if is_server:
|
||||
NetworkManager.start_server(4242, 8)
|
||||
else:
|
||||
NetworkManager.join_server("127.0.0.1", 4242)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Syntax prüfen**
|
||||
|
||||
Script im Godot-Editor öffnen → keine Fehler.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
# aus ruf-der-pilze/
|
||||
git add scripts/main.gd
|
||||
git commit -m "net: add main.gd with server/client mode detection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: main.tscn erstellen und als Hauptszene setzen
|
||||
|
||||
**Files:**
|
||||
- Create: `scenes/main.tscn`
|
||||
- Modify: `project.godot`
|
||||
|
||||
- [ ] **Step 1: Szene erstellen**
|
||||
|
||||
In Godot: **Scene → New Scene**
|
||||
- Root-Node: `Node` (nicht Node2D oder Node3D)
|
||||
- Node umbenennen zu: `Main`
|
||||
- Script anhängen: über den Inspector → Script-Slot → `res://scripts/main.gd` wählen
|
||||
- Speichern als: `res://scenes/main.tscn` (Ctrl+S)
|
||||
|
||||
- [ ] **Step 2: Als Hauptszene setzen**
|
||||
|
||||
**Project → Project Settings → Application → Run → Main Scene** → `res://scenes/main.tscn` auswählen
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
# aus ruf-der-pilze/
|
||||
git add scenes/main.tscn project.godot
|
||||
git commit -m "net: add main.tscn as root scene"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Integration testen — Server + Client
|
||||
|
||||
**Files:** keine Änderungen
|
||||
|
||||
- [ ] **Step 1: Server headless starten**
|
||||
|
||||
```bash
|
||||
# aus ruf-der-pilze/ — Binary-Name ggf. anpassen (siehe Task 0)
|
||||
godot --headless -- --server
|
||||
```
|
||||
|
||||
Erwartete Ausgabe:
|
||||
```
|
||||
[Server] Gestartet auf Port 4242
|
||||
```
|
||||
|
||||
Falls kein Output: `OS.get_cmdline_user_args()` prüfen — das `--` ist zwingend (trennt Godot-Args von User-Args). Ohne `--` landet `--server` in `get_cmdline_args()`, nicht in `get_cmdline_user_args()`.
|
||||
|
||||
- [ ] **Step 2: Client verbinden (Godot Editor)**
|
||||
|
||||
Godot-Editor → F5 (Projekt ausführen, kein `--server` Argument)
|
||||
|
||||
*Server-Terminal:*
|
||||
```
|
||||
[Server] Peer verbunden: 123456
|
||||
```
|
||||
|
||||
*Godot Output-Panel (Client):*
|
||||
```
|
||||
[Client] Verbunden. Meine peer_id: 123456
|
||||
[Client] Willkommen, meine peer_id ist: 123456
|
||||
```
|
||||
|
||||
Hinweis: Der Server printet `welcome` **nicht** — `call_remote` bedeutet die Funktion läuft nur auf dem Ziel-Client.
|
||||
|
||||
- [ ] **Step 3: Disconnect testen**
|
||||
|
||||
Client (Editor) schließen / F8 → Server-Terminal zeigt:
|
||||
```
|
||||
[Server] Peer getrennt: 123456
|
||||
```
|
||||
|
||||
- [ ] **Step 4: STATUS.md updaten**
|
||||
|
||||
`/home/mpuchstein/Dev/Godot/DnD_Anna_OneShot/docs/STATUS.md` öffnen und aktualisieren:
|
||||
|
||||
```markdown
|
||||
### ✅ Abgeschlossen
|
||||
- MCP-Addon eingerichtet
|
||||
- Projektstruktur angelegt
|
||||
- CLAUDE.md mit vollständiger Spielkonzept-Dokumentation
|
||||
- **Multiplayer Grundgerüst** — ENet Server/Client, NetworkManager Autoload, welcome RPC verifiziert
|
||||
|
||||
### ⏳ Als nächstes
|
||||
- Lobby + Rollen (Spieler registrieren sich mit Name + Rolle, DM kriegt Sonderrechte)
|
||||
Plan: noch zu erstellen
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Finaler Commit**
|
||||
|
||||
```bash
|
||||
# aus DnD_Anna_OneShot/ (Repo-Root, weil docs/ dort liegt)
|
||||
cd /home/mpuchstein/Dev/Godot/DnD_Anna_OneShot
|
||||
git add docs/STATUS.md
|
||||
git commit -m "docs: mark Multiplayer Grundgerüst as complete in STATUS.md"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verifikations-Checkliste
|
||||
|
||||
Vor "fertig" erklären:
|
||||
|
||||
- [ ] `[Server] Gestartet auf Port 4242` erscheint beim headless Start
|
||||
- [ ] `[Server] Peer verbunden: <id>` erscheint wenn Client verbindet
|
||||
- [ ] `[Client] Verbunden. Meine peer_id: <id>` erscheint im Client
|
||||
- [ ] `[Client] Willkommen, meine peer_id ist: <id>` erscheint im Client (RPC angekommen)
|
||||
- [ ] `[Server] Peer getrennt: <id>` erscheint wenn Client schließt
|
||||
- [ ] Kein `push_error` oder Parser-Fehler in keiner Ansicht
|
||||
4
ruf-der-pilze/.editorconfig
Normal file
4
ruf-der-pilze/.editorconfig
Normal file
@@ -0,0 +1,4 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
2
ruf-der-pilze/.gitattributes
vendored
Normal file
2
ruf-der-pilze/.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
* text=auto eol=lf
|
||||
3
ruf-der-pilze/.gitignore
vendored
Normal file
3
ruf-der-pilze/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# Godot 4+ specific ignores
|
||||
.godot/
|
||||
/android/
|
||||
220
ruf-der-pilze/CLAUDE.md
Normal file
220
ruf-der-pilze/CLAUDE.md
Normal file
@@ -0,0 +1,220 @@
|
||||
# Der Ruf der Pilze — Projekt-Kontext für Claude Code
|
||||
|
||||
## Was wir bauen
|
||||
|
||||
Ein atmosphärischer Multiplayer One-Shot für D&D 5e, gespielt über Godot 4.
|
||||
Kein klassisches Spiel — eher eine interaktive Bühne für eine Gruppe von 3–5 Spielern + 1 DM.
|
||||
|
||||
**Kernmechanik:** Sporen-induzierte asymmetrische Wahrnehmung. Jeder Spieler sieht eine
|
||||
andere Realität. Die Rätsel sind nur durch Kommunikation zwischen den Spielern lösbar.
|
||||
Das Spiel verrät den Spielern nie direkt, dass sie verschiedenes sehen — sie entdecken
|
||||
es selbst im Gespräch.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Engine:** Godot 4.x (GDScript)
|
||||
- **Multiplayer:** Godot native MultiplayerAPI (ENet/WebSocket), kein Mirror
|
||||
- **MCP:** @satelliteoflove/godot-mcp (Screenshot-Feedback-Loop mit Claude Code)
|
||||
- **Hosting:** VPS, Godot Dedicated Server (headless)
|
||||
- **Plattform:** Arch Linux
|
||||
|
||||
---
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
VPS — Godot Dedicated Server (headless)
|
||||
├── GameState — welcher Spieler welche Realität sieht (Dictionary peer_id → state)
|
||||
├── RoomManager — Türen, Trigger, Rätsel-States pro Raum
|
||||
└── SporeLevel — Sporennebel-Dichte pro Raum (0.0 – 1.0)
|
||||
|
||||
Spieler-Client (Ego-Perspektive, First Person)
|
||||
├── PlayerController — Bewegung, Interaktion
|
||||
├── OverlayManager — empfängt per rpc_id() nur seinen eigenen Overlay-State
|
||||
├── AudioManager — direktionaler Audio lokal berechnet
|
||||
└── DiceRoller — d20 + Modifier, Ergebnis wird an alle gebroadcastet
|
||||
|
||||
DM-Client (Top-Down-Ansicht)
|
||||
└── RegiepultUI
|
||||
├── Spieler-Positionen live
|
||||
├── Overlay-Toggle pro Spieler
|
||||
├── Sporennebel-Slider pro Raum
|
||||
├── Türen/Geometrie manipulieren
|
||||
├── Audio-Trigger (Annas Spieluhr-Lied, direktional)
|
||||
└── DC-Anzeige bei Würfelwürfen
|
||||
```
|
||||
|
||||
**Asymmetrische Wahrnehmung** läuft über `rpc_id()` — der Server schickt jedem
|
||||
Spieler nur seinen eigenen Overlay-State:
|
||||
|
||||
```gdscript
|
||||
# Nur an einen bestimmten Spieler schicken
|
||||
rpc_id(peer_id, "show_overlay", "living_monastery")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Story
|
||||
|
||||
Die Spielergruppe verbringt einen Abend in einer Taverne. Am nächsten Morgen
|
||||
ist **Anna** verschwunden — Robe halb zersetzt auf dem Boden, eine Myzelspur
|
||||
führt aus dem Zimmer in den Wald.
|
||||
|
||||
Das Kloster am Waldrand trägt das Symbol von Annas Orden. Die Spieler erkunden
|
||||
es, erleben Sporen-induzierte Halluzinationen, sehen verschiedene Realitäten
|
||||
und müssen zusammenarbeiten um zu Anna zu gelangen.
|
||||
|
||||
Am Ende finden sie Anna — lächelnd, verbunden mit dem Myzel, vollständig ruhig.
|
||||
|
||||
> *"Ich bin nicht verloren. Immer gefunden. Ich bin ja schon groß."*
|
||||
|
||||
Alle wachen in der Taverne auf. Anna sitzt beim Frühstück. Lächelt. Sagt nichts.
|
||||
Die Spieler schauen auf ihre eigenen Hände — Dreck unter den Fingernägeln.
|
||||
War es ein Traum? Hat **Putrescor** sie kurz ins Netzwerk gelassen?
|
||||
|
||||
**Offen. Für immer.**
|
||||
|
||||
### Putrescor
|
||||
Annas Patron (Spore Druid / Great Old One Warlock). Ein Myzel-Bewusstsein das
|
||||
alles verbindet was je gewachsen und verrottet ist. Kommuniziert nicht in Worten
|
||||
— in Verbindung. Das Kloster ist kein Ort, es ist eine Erinnerung im Netzwerk.
|
||||
|
||||
---
|
||||
|
||||
## Raumstruktur
|
||||
|
||||
```
|
||||
[START] Taverne → Myzelpfad → Klostertor
|
||||
│
|
||||
┌─────────▼─────────┐
|
||||
│ EINGANGSHALLE │ ← Atmosphäre, kein Rätsel
|
||||
└─────────┬─────────┘
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
REFEKTORIUM BIBLIOTHEK KAPELLE
|
||||
"Zwei Welten" "Flüsternetz" "Spiegelbild"
|
||||
└───────────────┼───────────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ KREUZGANG │ ← Optionaler Wächter
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ SANCTUM │ ← "Annas Lied"
|
||||
└────────┬────────┘
|
||||
▼
|
||||
ANNA
|
||||
```
|
||||
|
||||
Die drei mittleren Räume (Refektorium, Bibliothek, Kapelle) können in
|
||||
**beliebiger Reihenfolge** gespielt werden. Erst wenn alle drei gelöst sind,
|
||||
öffnet sich der Kreuzgang.
|
||||
|
||||
---
|
||||
|
||||
## Rätsel im Detail
|
||||
|
||||
### 🍽️ Refektorium — "Zwei Welten, ein Raum"
|
||||
- **2 Spieler** sehen lebendiges Kloster: Mönche, Kerzen, Gesang
|
||||
- **2–3 Spieler** sehen Ruinen: Skelette, Schimmel, Mondlicht
|
||||
- Ein Mönch (nur Gruppe A sichtbar) klopft an einen Stein in der Wand
|
||||
- Gruppe B sieht nur verwitterten Stein — aber der Stein ist hohl
|
||||
- Dahinter: **Schlüssel Nr. 1**
|
||||
- DM-Toggle: welche Spieler welche Realität sehen
|
||||
- Checks: Investigation DC 12 (Gruppe B), Perception DC 10 (alle)
|
||||
|
||||
### 📚 Bibliothek — "Das Flüsternetz"
|
||||
- Myzelfasern in den Regalen flüstern — jeder Spieler hört ein anderes Fragment
|
||||
- Spieler A: *"...wenn der erste Mond..."*
|
||||
- Spieler B: *"...das Feuer erlischt..."*
|
||||
- Spieler C: *"...sprich ihren Namen..."*
|
||||
- Spieler D: *"...und der Kreis sich schließt"*
|
||||
- Zusammen: *"Wenn der erste Mond das Feuer erlischt, sprich ihren Namen und der Kreis sich schließt."*
|
||||
- Kerzenkreis im Raum: alle Kerzen löschen + "Anna" sagen → Geheimfach → **Schlüssel Nr. 2**
|
||||
- Bei hoher Sporendichte: Fragmente leicht verzerren (ein Spieler hört "deinen" statt "ihren")
|
||||
- Checks: Perception DC 13 (verzerrt), Arcana DC 11 (Ritual)
|
||||
|
||||
### ⛪ Kapelle — "Das ehrliche Spiegelbild"
|
||||
- Großer Spiegel am Altar, jeder sieht etwas anderes:
|
||||
- Spieler A: eigenes Gesicht, Augen geschlossen
|
||||
- Spieler B: den Raum ohne sich selbst
|
||||
- Spieler C: Anna, die winkt
|
||||
- Spieler D: Spiegel mit Riss — dahinter Licht
|
||||
- Nur einer sieht die Wahrheit: hinter dem Spiegel ist ein Hebel → **Schlüssel Nr. 3**
|
||||
- Falsche Aktion: 3-Sekunden-Halluzination für alle — sie sehen Anna, die lächelt
|
||||
- **Kein mechanischer Hinweis** wer die Wahrheit sieht — reines Rollenspiel
|
||||
- Checks: Insight DC 14, Investigation DC 12
|
||||
|
||||
### 🌿 Kreuzgang — Optionaler Wächter
|
||||
- Alle drei Lösungen an drei Symbolen an der Wand einsetzen
|
||||
- Wächter (Mönch aus Pilzgeflecht) erscheint wenn Gruppe zu laut/hastig war
|
||||
- Checks: Nature DC 13 (Vorteil für Druid/Ranger), Persuasion DC 15, Stealth DC 12 (alle)
|
||||
- **Kein Kampf** — nur Skill Checks
|
||||
|
||||
### 🌑 Sanctum — "Annas Lied"
|
||||
- Dunkel, nur Biolumineszenz der Pilze
|
||||
- Spieluhr-Melodie klingt für jeden aus anderer Richtung
|
||||
- Wenn alle gleichzeitig in ihre Richtung schauen: bilden sie einen Kreis
|
||||
- In der Mitte: Anna
|
||||
- **Kein Check** — reines Erzählen
|
||||
|
||||
---
|
||||
|
||||
## Sporennebel
|
||||
|
||||
Steigt mit jeder Ebene. DM-steuerbar per Slider im Regiepult.
|
||||
|
||||
| Raum | Dichte | Effekt |
|
||||
|------|--------|--------|
|
||||
| Eingangshalle | Gering | Atmosphäre, keine Checks |
|
||||
| Refektorium / Bibliothek / Kapelle | Mittel | Checks können verzerrt sein |
|
||||
| Kreuzgang | Hoch | Stärkere Overlays möglich |
|
||||
| Sanctum | Maximum | Reine Erzählung |
|
||||
|
||||
---
|
||||
|
||||
## Würfel
|
||||
|
||||
Nur ein einfacher d20-Roller nötig:
|
||||
```
|
||||
[ 🎲 d20 ] Modifier: [+3] → Ergebnis: 17
|
||||
```
|
||||
- Wurf sichtbar für alle Spieler + DM
|
||||
- DM sieht DC im Regiepult (Spieler nicht)
|
||||
- Kein Kampfsystem, kein Initiative-Tracker, kein vollständiges Charsheet ingame
|
||||
|
||||
---
|
||||
|
||||
## Anna (Spieler-Charakter, wird vom DM gespielt)
|
||||
|
||||
- **Klasse:** Circle of Spores Druid 3 / Great Old One Warlock 2
|
||||
- **Patron:** Putrescor (das Myzel-Netzwerk selbst)
|
||||
- **Erscheinung:** zerfetzte Ordensroben durchwoben mit Myzel, gräuliche Haut,
|
||||
Pilze die auf ihr wachsen, Myzelfäden aus den Fingern, Augen etwas zu groß,
|
||||
Lächeln etwas zu lang gehalten
|
||||
- **Persönlichkeit:** enthusiastisch fröhlich, distanzierte Neugier gegenüber
|
||||
Leid und Verfall, kein Böswille — alles ist natürlicher Prozess
|
||||
- Im One-Shot: NPC, taucht erst im Sanctum auf
|
||||
|
||||
---
|
||||
|
||||
## Entwicklungs-Reihenfolge
|
||||
|
||||
1. ✅ MCP eingerichtet (@satelliteoflove/godot-mcp, Claude Code verbunden)
|
||||
2. Multiplayer Grundgerüst (Server, Clients verbinden, rpc testen)
|
||||
3. Erster Raum — Refektorium mit asymmetrischer Wahrnehmung
|
||||
4. DM Regiepult Basics — Overlay-Toggle
|
||||
5. Alle Räume aufbauen
|
||||
6. Polish — Audio, Nebel, Licht, Würfel-UI
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Designprinzipien
|
||||
|
||||
- Das System sagt den Spielern **nie** dass sie verschiedenes sehen
|
||||
- Die soziale Kommunikationsschicht **ist** die Mechanik
|
||||
- Das Ende (Dreck unter den Fingernägeln) bleibt **für immer offen**
|
||||
- Kein Kampf — die Atmosphäre ist das Spiel
|
||||
- DM hat volle Kontrolle über alle Wahrnehmungen in Echtzeit
|
||||
40
ruf-der-pilze/addons/godot_mcp/command_router.gd
Normal file
40
ruf-der-pilze/addons/godot_mcp/command_router.gd
Normal file
@@ -0,0 +1,40 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name MCPCommandRouter
|
||||
|
||||
var _commands: Dictionary = {}
|
||||
var _handlers: Array[MCPBaseCommand] = []
|
||||
|
||||
|
||||
func setup(plugin: EditorPlugin) -> void:
|
||||
_register_handler(MCPSystemCommands.new(), plugin)
|
||||
_register_handler(MCPSceneCommands.new(), plugin)
|
||||
_register_handler(MCPNodeCommands.new(), plugin)
|
||||
_register_handler(MCPScriptCommands.new(), plugin)
|
||||
_register_handler(MCPSelectionCommands.new(), plugin)
|
||||
_register_handler(MCPProjectCommands.new(), plugin)
|
||||
_register_handler(MCPDebugCommands.new(), plugin)
|
||||
_register_handler(MCPScreenshotCommands.new(), plugin)
|
||||
_register_handler(MCPAnimationCommands.new(), plugin)
|
||||
_register_handler(MCPTilemapCommands.new(), plugin)
|
||||
_register_handler(MCPResourceCommands.new(), plugin)
|
||||
_register_handler(MCPScene3DCommands.new(), plugin)
|
||||
_register_handler(MCPInputCommands.new(), plugin)
|
||||
_register_handler(MCPProfilerCommands.new(), plugin)
|
||||
|
||||
|
||||
func _register_handler(handler: MCPBaseCommand, plugin: EditorPlugin) -> void:
|
||||
handler.setup(plugin)
|
||||
_handlers.append(handler)
|
||||
var cmds := handler.get_commands()
|
||||
for cmd_name in cmds:
|
||||
_commands[cmd_name] = cmds[cmd_name]
|
||||
|
||||
|
||||
func handle_command(command: String, params: Dictionary):
|
||||
if not _commands.has(command):
|
||||
return MCPUtils.error("UNKNOWN_COMMAND", "Unknown command: %s" % command)
|
||||
|
||||
var callable: Callable = _commands[command]
|
||||
var result = await callable.call(params)
|
||||
return result
|
||||
1
ruf-der-pilze/addons/godot_mcp/command_router.gd.uid
Normal file
1
ruf-der-pilze/addons/godot_mcp/command_router.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dx5jdjvtk6qce
|
||||
633
ruf-der-pilze/addons/godot_mcp/commands/animation_commands.gd
Normal file
633
ruf-der-pilze/addons/godot_mcp/commands/animation_commands.gd
Normal file
@@ -0,0 +1,633 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPAnimationCommands
|
||||
|
||||
|
||||
const TRACK_TYPE_MAP := {
|
||||
"value": Animation.TYPE_VALUE,
|
||||
"position_3d": Animation.TYPE_POSITION_3D,
|
||||
"rotation_3d": Animation.TYPE_ROTATION_3D,
|
||||
"scale_3d": Animation.TYPE_SCALE_3D,
|
||||
"blend_shape": Animation.TYPE_BLEND_SHAPE,
|
||||
"method": Animation.TYPE_METHOD,
|
||||
"bezier": Animation.TYPE_BEZIER,
|
||||
"audio": Animation.TYPE_AUDIO,
|
||||
"animation": Animation.TYPE_ANIMATION
|
||||
}
|
||||
|
||||
const LOOP_MODE_MAP := {
|
||||
"none": Animation.LOOP_NONE,
|
||||
"linear": Animation.LOOP_LINEAR,
|
||||
"pingpong": Animation.LOOP_PINGPONG
|
||||
}
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"list_animation_players": list_animation_players,
|
||||
"get_animation_player_info": get_animation_player_info,
|
||||
"get_animation_details": get_animation_details,
|
||||
"get_track_keyframes": get_track_keyframes,
|
||||
"play_animation": play_animation,
|
||||
"stop_animation": stop_animation,
|
||||
"seek_animation": seek_animation,
|
||||
"create_animation": create_animation,
|
||||
"delete_animation": delete_animation,
|
||||
"update_animation_properties": update_animation_properties,
|
||||
"add_animation_track": add_animation_track,
|
||||
"remove_animation_track": remove_animation_track,
|
||||
"add_keyframe": add_keyframe,
|
||||
"remove_keyframe": remove_keyframe,
|
||||
"update_keyframe": update_keyframe
|
||||
}
|
||||
|
||||
|
||||
func _get_animation_player(node_path: String) -> AnimationPlayer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return null
|
||||
if not node is AnimationPlayer:
|
||||
return null
|
||||
return node as AnimationPlayer
|
||||
|
||||
|
||||
func _get_animation(player: AnimationPlayer, anim_name: String) -> Animation:
|
||||
if not player.has_animation(anim_name):
|
||||
return null
|
||||
return player.get_animation(anim_name)
|
||||
|
||||
|
||||
func _track_type_to_string(track_type: int) -> String:
|
||||
for key in TRACK_TYPE_MAP:
|
||||
if TRACK_TYPE_MAP[key] == track_type:
|
||||
return key
|
||||
return "unknown"
|
||||
|
||||
|
||||
func _loop_mode_to_string(loop_mode: int) -> String:
|
||||
for key in LOOP_MODE_MAP:
|
||||
if LOOP_MODE_MAP[key] == loop_mode:
|
||||
return key
|
||||
return "none"
|
||||
|
||||
|
||||
func _find_animation_players(node: Node, result: Array, root: Node) -> void:
|
||||
if node is AnimationPlayer:
|
||||
var relative_path := str(root.get_path_to(node))
|
||||
result.append({
|
||||
"path": relative_path,
|
||||
"name": node.name
|
||||
})
|
||||
for child in node.get_children():
|
||||
_find_animation_players(child, result, root)
|
||||
|
||||
|
||||
func list_animation_players(params: Dictionary) -> Dictionary:
|
||||
var root_path: String = params.get("root_path", "")
|
||||
var root: Node
|
||||
|
||||
if root_path.is_empty():
|
||||
root = EditorInterface.get_edited_scene_root()
|
||||
else:
|
||||
root = _get_node(root_path)
|
||||
|
||||
if not root:
|
||||
return _error("NODE_NOT_FOUND", "Root node not found")
|
||||
|
||||
var players := []
|
||||
_find_animation_players(root, players, root)
|
||||
|
||||
return _success({"animation_players": players})
|
||||
|
||||
|
||||
func get_animation_player_info(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer: %s" % node_path)
|
||||
|
||||
var libraries := {}
|
||||
for lib_name in player.get_animation_library_list():
|
||||
var lib := player.get_animation_library(lib_name)
|
||||
libraries[lib_name] = Array(lib.get_animation_list())
|
||||
|
||||
return _success({
|
||||
"current_animation": player.current_animation,
|
||||
"is_playing": player.is_playing(),
|
||||
"current_position": player.current_animation_position,
|
||||
"speed_scale": player.speed_scale,
|
||||
"libraries": libraries,
|
||||
"animation_count": player.get_animation_list().size()
|
||||
})
|
||||
|
||||
|
||||
func get_animation_details(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
var anim := _get_animation(player, anim_name)
|
||||
if not anim:
|
||||
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
|
||||
|
||||
var tracks := []
|
||||
for i in range(anim.get_track_count()):
|
||||
tracks.append({
|
||||
"index": i,
|
||||
"type": _track_type_to_string(anim.track_get_type(i)),
|
||||
"path": str(anim.track_get_path(i)),
|
||||
"interpolation": anim.track_get_interpolation_type(i),
|
||||
"keyframe_count": anim.track_get_key_count(i)
|
||||
})
|
||||
|
||||
var lib_name := ""
|
||||
var pure_name := anim_name
|
||||
if "/" in anim_name:
|
||||
var parts := anim_name.split("/", true, 1)
|
||||
lib_name = parts[0]
|
||||
pure_name = parts[1]
|
||||
|
||||
return _success({
|
||||
"name": pure_name,
|
||||
"library": lib_name,
|
||||
"length": anim.length,
|
||||
"loop_mode": _loop_mode_to_string(anim.loop_mode),
|
||||
"step": anim.step,
|
||||
"track_count": anim.get_track_count(),
|
||||
"tracks": tracks
|
||||
})
|
||||
|
||||
|
||||
func get_track_keyframes(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
var track_index: int = params.get("track_index", -1)
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
if track_index < 0:
|
||||
return _error("INVALID_PARAMS", "track_index is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
var anim := _get_animation(player, anim_name)
|
||||
if not anim:
|
||||
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
|
||||
|
||||
if track_index >= anim.get_track_count():
|
||||
return _error("TRACK_NOT_FOUND", "Track index out of range: %d" % track_index)
|
||||
|
||||
var keyframes := []
|
||||
var track_type := anim.track_get_type(track_index)
|
||||
|
||||
for i in range(anim.track_get_key_count(track_index)):
|
||||
var kf := {
|
||||
"time": anim.track_get_key_time(track_index, i),
|
||||
"transition": anim.track_get_key_transition(track_index, i)
|
||||
}
|
||||
|
||||
match track_type:
|
||||
Animation.TYPE_METHOD:
|
||||
kf["method"] = anim.method_track_get_name(track_index, i)
|
||||
kf["args"] = anim.method_track_get_params(track_index, i)
|
||||
Animation.TYPE_BEZIER:
|
||||
kf["value"] = anim.bezier_track_get_key_value(track_index, i)
|
||||
kf["in_handle"] = _serialize_value(anim.bezier_track_get_key_in_handle(track_index, i))
|
||||
kf["out_handle"] = _serialize_value(anim.bezier_track_get_key_out_handle(track_index, i))
|
||||
_:
|
||||
kf["value"] = _serialize_value(anim.track_get_key_value(track_index, i))
|
||||
|
||||
keyframes.append(kf)
|
||||
|
||||
return _success({
|
||||
"track_path": str(anim.track_get_path(track_index)),
|
||||
"track_type": _track_type_to_string(track_type),
|
||||
"keyframes": keyframes
|
||||
})
|
||||
|
||||
|
||||
func play_animation(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
var custom_blend: float = params.get("custom_blend", -1.0)
|
||||
var custom_speed: float = params.get("custom_speed", 1.0)
|
||||
var from_end: bool = params.get("from_end", false)
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
if not player.has_animation(anim_name):
|
||||
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
|
||||
|
||||
player.play(anim_name, custom_blend, custom_speed, from_end)
|
||||
|
||||
return _success({"playing": anim_name, "from_position": player.current_animation_position})
|
||||
|
||||
|
||||
func stop_animation(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var keep_state: bool = params.get("keep_state", false)
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
player.stop(keep_state)
|
||||
|
||||
return _success({"stopped": true})
|
||||
|
||||
|
||||
func seek_animation(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var seconds: float = params.get("seconds", 0.0)
|
||||
var update: bool = params.get("update", true)
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("seconds"):
|
||||
return _error("INVALID_PARAMS", "seconds is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
player.seek(seconds, update)
|
||||
|
||||
return _success({"position": player.current_animation_position})
|
||||
|
||||
|
||||
func create_animation(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
var lib_name: String = params.get("library_name", "")
|
||||
var length: float = params.get("length", 1.0)
|
||||
var loop_mode: String = params.get("loop_mode", "none")
|
||||
var step: float = params.get("step", 0.1)
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
var lib: AnimationLibrary
|
||||
if player.has_animation_library(lib_name):
|
||||
lib = player.get_animation_library(lib_name)
|
||||
else:
|
||||
lib = AnimationLibrary.new()
|
||||
player.add_animation_library(lib_name, lib)
|
||||
|
||||
if lib.has_animation(anim_name):
|
||||
return _error("ANIMATION_EXISTS", "Animation already exists: %s" % anim_name)
|
||||
|
||||
var anim := Animation.new()
|
||||
anim.length = length
|
||||
if LOOP_MODE_MAP.has(loop_mode):
|
||||
anim.loop_mode = LOOP_MODE_MAP[loop_mode]
|
||||
anim.step = step
|
||||
|
||||
var err := lib.add_animation(anim_name, anim)
|
||||
if err != OK:
|
||||
return _error("CREATE_FAILED", "Failed to create animation: %s" % error_string(err))
|
||||
|
||||
return _success({"created": anim_name, "library": lib_name})
|
||||
|
||||
|
||||
func delete_animation(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
var lib_name: String = params.get("library_name", "")
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
if not player.has_animation_library(lib_name):
|
||||
return _error("LIBRARY_NOT_FOUND", "Animation library not found: %s" % lib_name)
|
||||
|
||||
var lib := player.get_animation_library(lib_name)
|
||||
if not lib.has_animation(anim_name):
|
||||
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
|
||||
|
||||
lib.remove_animation(anim_name)
|
||||
|
||||
return _success({"deleted": anim_name})
|
||||
|
||||
|
||||
func update_animation_properties(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
var anim := _get_animation(player, anim_name)
|
||||
if not anim:
|
||||
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
|
||||
|
||||
var updated := {}
|
||||
|
||||
if params.has("length"):
|
||||
anim.length = params["length"]
|
||||
updated["length"] = anim.length
|
||||
|
||||
if params.has("loop_mode"):
|
||||
var loop_str: String = params["loop_mode"]
|
||||
if LOOP_MODE_MAP.has(loop_str):
|
||||
anim.loop_mode = LOOP_MODE_MAP[loop_str]
|
||||
updated["loop_mode"] = loop_str
|
||||
|
||||
if params.has("step"):
|
||||
anim.step = params["step"]
|
||||
updated["step"] = anim.step
|
||||
|
||||
return _success({"updated": anim_name, "properties": updated})
|
||||
|
||||
|
||||
func add_animation_track(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
var track_type: String = params.get("track_type", "")
|
||||
var track_path: String = params.get("track_path", "")
|
||||
var insert_at: int = params.get("insert_at", -1)
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
if track_type.is_empty():
|
||||
return _error("INVALID_PARAMS", "track_type is required")
|
||||
if track_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "track_path is required")
|
||||
|
||||
if not TRACK_TYPE_MAP.has(track_type):
|
||||
return _error("INVALID_TRACK_TYPE", "Invalid track type: %s. Valid types: %s" % [track_type, ", ".join(TRACK_TYPE_MAP.keys())])
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
var anim := _get_animation(player, anim_name)
|
||||
if not anim:
|
||||
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
|
||||
|
||||
var godot_track_type: int = TRACK_TYPE_MAP[track_type]
|
||||
var track_index: int
|
||||
|
||||
if insert_at >= 0:
|
||||
track_index = anim.add_track(godot_track_type, insert_at)
|
||||
else:
|
||||
track_index = anim.add_track(godot_track_type)
|
||||
|
||||
anim.track_set_path(track_index, track_path)
|
||||
|
||||
return _success({
|
||||
"track_index": track_index,
|
||||
"track_path": track_path,
|
||||
"track_type": track_type
|
||||
})
|
||||
|
||||
|
||||
func remove_animation_track(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
var track_index: int = params.get("track_index", -1)
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
if track_index < 0:
|
||||
return _error("INVALID_PARAMS", "track_index is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
var anim := _get_animation(player, anim_name)
|
||||
if not anim:
|
||||
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
|
||||
|
||||
if track_index >= anim.get_track_count():
|
||||
return _error("TRACK_NOT_FOUND", "Track index out of range: %d" % track_index)
|
||||
|
||||
anim.remove_track(track_index)
|
||||
|
||||
return _success({"removed_track": track_index})
|
||||
|
||||
|
||||
func add_keyframe(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
var track_index: int = params.get("track_index", -1)
|
||||
var time: float = params.get("time", 0.0)
|
||||
var transition: float = params.get("transition", 1.0)
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
if track_index < 0:
|
||||
return _error("INVALID_PARAMS", "track_index is required")
|
||||
if not params.has("time"):
|
||||
return _error("INVALID_PARAMS", "time is required")
|
||||
if not params.has("value"):
|
||||
return _error("INVALID_PARAMS", "value is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
var anim := _get_animation(player, anim_name)
|
||||
if not anim:
|
||||
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
|
||||
|
||||
if track_index >= anim.get_track_count():
|
||||
return _error("TRACK_NOT_FOUND", "Track index out of range: %d" % track_index)
|
||||
|
||||
var value = MCPUtils.deserialize_value(params["value"])
|
||||
var track_type := anim.track_get_type(track_index)
|
||||
var key_index: int
|
||||
|
||||
match track_type:
|
||||
Animation.TYPE_BEZIER:
|
||||
key_index = anim.bezier_track_insert_key(track_index, time, value)
|
||||
Animation.TYPE_METHOD:
|
||||
var method_name: String = params.get("method_name", "")
|
||||
var args: Array = params.get("args", [])
|
||||
key_index = anim.method_track_add_key(track_index, time, method_name, args)
|
||||
_:
|
||||
key_index = anim.track_insert_key(track_index, time, value, transition)
|
||||
|
||||
return _success({
|
||||
"keyframe_index": key_index,
|
||||
"time": time,
|
||||
"value": _serialize_value(value)
|
||||
})
|
||||
|
||||
|
||||
func remove_keyframe(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
var track_index: int = params.get("track_index", -1)
|
||||
var keyframe_index: int = params.get("keyframe_index", -1)
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
if track_index < 0:
|
||||
return _error("INVALID_PARAMS", "track_index is required")
|
||||
if keyframe_index < 0:
|
||||
return _error("INVALID_PARAMS", "keyframe_index is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
var anim := _get_animation(player, anim_name)
|
||||
if not anim:
|
||||
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
|
||||
|
||||
if track_index >= anim.get_track_count():
|
||||
return _error("TRACK_NOT_FOUND", "Track index out of range: %d" % track_index)
|
||||
|
||||
if keyframe_index >= anim.track_get_key_count(track_index):
|
||||
return _error("KEYFRAME_NOT_FOUND", "Keyframe index out of range: %d" % keyframe_index)
|
||||
|
||||
anim.track_remove_key(track_index, keyframe_index)
|
||||
|
||||
return _success({"removed_keyframe": keyframe_index, "track_index": track_index})
|
||||
|
||||
|
||||
func update_keyframe(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var anim_name: String = params.get("animation_name", "")
|
||||
var track_index: int = params.get("track_index", -1)
|
||||
var keyframe_index: int = params.get("keyframe_index", -1)
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if anim_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "animation_name is required")
|
||||
if track_index < 0:
|
||||
return _error("INVALID_PARAMS", "track_index is required")
|
||||
if keyframe_index < 0:
|
||||
return _error("INVALID_PARAMS", "keyframe_index is required")
|
||||
|
||||
var player := _get_animation_player(node_path)
|
||||
if not player:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
|
||||
|
||||
var anim := _get_animation(player, anim_name)
|
||||
if not anim:
|
||||
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
|
||||
|
||||
if track_index >= anim.get_track_count():
|
||||
return _error("TRACK_NOT_FOUND", "Track index out of range: %d" % track_index)
|
||||
|
||||
if keyframe_index >= anim.track_get_key_count(track_index):
|
||||
return _error("KEYFRAME_NOT_FOUND", "Keyframe index out of range: %d" % keyframe_index)
|
||||
|
||||
var result := {}
|
||||
|
||||
if params.has("time"):
|
||||
var new_time: float = params["time"]
|
||||
var old_value = anim.track_get_key_value(track_index, keyframe_index)
|
||||
var old_transition := anim.track_get_key_transition(track_index, keyframe_index)
|
||||
anim.track_remove_key(track_index, keyframe_index)
|
||||
keyframe_index = anim.track_insert_key(track_index, new_time, old_value, old_transition)
|
||||
result["time"] = new_time
|
||||
result["keyframe_index"] = keyframe_index
|
||||
|
||||
if params.has("value"):
|
||||
var new_value = MCPUtils.deserialize_value(params["value"])
|
||||
anim.track_set_key_value(track_index, keyframe_index, new_value)
|
||||
result["value"] = _serialize_value(new_value)
|
||||
|
||||
if params.has("transition"):
|
||||
var new_transition: float = params["transition"]
|
||||
anim.track_set_key_transition(track_index, keyframe_index, new_transition)
|
||||
result["transition"] = new_transition
|
||||
|
||||
return _success({"updated_keyframe": keyframe_index, "changes": result})
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3hb6slopq75j
|
||||
134
ruf-der-pilze/addons/godot_mcp/commands/debug_commands.gd
Normal file
134
ruf-der-pilze/addons/godot_mcp/commands/debug_commands.gd
Normal file
@@ -0,0 +1,134 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPDebugCommands
|
||||
|
||||
const DEBUG_OUTPUT_TIMEOUT := 5.0
|
||||
|
||||
var _debug_output_result: PackedStringArray = []
|
||||
var _debug_output_pending: bool = false
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"run_project": run_project,
|
||||
"stop_project": stop_project,
|
||||
"get_debug_output": get_debug_output,
|
||||
"get_log_messages": get_log_messages,
|
||||
"get_errors": get_errors,
|
||||
"get_stack_trace": get_stack_trace,
|
||||
}
|
||||
|
||||
|
||||
func run_project(params: Dictionary) -> Dictionary:
|
||||
var scene_path: String = params.get("scene_path", "")
|
||||
|
||||
MCPLogger.clear()
|
||||
|
||||
if scene_path.is_empty():
|
||||
EditorInterface.play_main_scene()
|
||||
else:
|
||||
EditorInterface.play_custom_scene(scene_path)
|
||||
|
||||
return _success({})
|
||||
|
||||
|
||||
func stop_project(_params: Dictionary) -> Dictionary:
|
||||
EditorInterface.stop_playing_scene()
|
||||
return _success({})
|
||||
|
||||
|
||||
func get_debug_output(params: Dictionary) -> Dictionary:
|
||||
var clear: bool = params.get("clear", false)
|
||||
var source: String = params.get("source", "")
|
||||
|
||||
if source == "editor":
|
||||
var output := "\n".join(MCPLogger.get_output())
|
||||
if clear:
|
||||
MCPLogger.clear()
|
||||
return _success({"output": output, "source": "editor"})
|
||||
|
||||
if source == "game":
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return _error("NOT_RUNNING", "No game is currently running. Use source: 'editor' for editor output.")
|
||||
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
||||
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
||||
return _error("NO_SESSION", "No active debug session. Use source: 'editor' for editor output.")
|
||||
return await _fetch_game_debug_output(debugger_plugin, clear)
|
||||
|
||||
if not EditorInterface.is_playing_scene():
|
||||
var output := "\n".join(MCPLogger.get_output())
|
||||
if clear:
|
||||
MCPLogger.clear()
|
||||
return _success({"output": output, "source": "editor"})
|
||||
|
||||
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
||||
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
||||
var output := "\n".join(MCPLogger.get_output())
|
||||
if clear:
|
||||
MCPLogger.clear()
|
||||
return _success({"output": output, "source": "editor"})
|
||||
|
||||
return await _fetch_game_debug_output(debugger_plugin, clear)
|
||||
|
||||
|
||||
func _fetch_game_debug_output(debugger_plugin: MCPDebuggerPlugin, clear: bool) -> Dictionary:
|
||||
_debug_output_pending = true
|
||||
_debug_output_result = PackedStringArray()
|
||||
|
||||
debugger_plugin.debug_output_received.connect(_on_debug_output_received, CONNECT_ONE_SHOT)
|
||||
debugger_plugin.request_debug_output(clear)
|
||||
|
||||
var start_time := Time.get_ticks_msec()
|
||||
while _debug_output_pending:
|
||||
await Engine.get_main_loop().process_frame
|
||||
if (Time.get_ticks_msec() - start_time) / 1000.0 > DEBUG_OUTPUT_TIMEOUT:
|
||||
_debug_output_pending = false
|
||||
if debugger_plugin.debug_output_received.is_connected(_on_debug_output_received):
|
||||
debugger_plugin.debug_output_received.disconnect(_on_debug_output_received)
|
||||
return _success({"output": "\n".join(MCPLogger.get_output()), "source": "editor"})
|
||||
|
||||
return _success({"output": "\n".join(_debug_output_result), "source": "game"})
|
||||
|
||||
|
||||
func _on_debug_output_received(output: PackedStringArray) -> void:
|
||||
_debug_output_pending = false
|
||||
_debug_output_result = output
|
||||
|
||||
|
||||
func get_log_messages(params: Dictionary) -> Dictionary:
|
||||
var clear: bool = params.get("clear", false)
|
||||
var limit: int = params.get("limit", 50)
|
||||
|
||||
var all_messages := MCPLogger.get_errors()
|
||||
var total_count := all_messages.size()
|
||||
|
||||
var limited: Array[Dictionary] = []
|
||||
var start_index := maxi(0, total_count - limit)
|
||||
for i in range(start_index, total_count):
|
||||
limited.append(all_messages[i])
|
||||
|
||||
if clear:
|
||||
MCPLogger.clear_errors()
|
||||
|
||||
return _success({
|
||||
"total_count": total_count,
|
||||
"returned_count": limited.size(),
|
||||
"messages": limited,
|
||||
})
|
||||
|
||||
|
||||
func get_errors(params: Dictionary) -> Dictionary:
|
||||
return get_log_messages(params)
|
||||
|
||||
|
||||
func get_stack_trace(_params: Dictionary) -> Dictionary:
|
||||
var frames := MCPLogger.get_last_stack_trace()
|
||||
var errors := MCPLogger.get_errors()
|
||||
var last_error: Dictionary = errors[-1] if not errors.is_empty() else {}
|
||||
return _success({
|
||||
"error": last_error.get("message", ""),
|
||||
"error_type": last_error.get("type", ""),
|
||||
"file": last_error.get("file", ""),
|
||||
"line": last_error.get("line", 0),
|
||||
"frames": frames,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfm205ak170xo
|
||||
193
ruf-der-pilze/addons/godot_mcp/commands/input_commands.gd
Normal file
193
ruf-der-pilze/addons/godot_mcp/commands/input_commands.gd
Normal file
@@ -0,0 +1,193 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPInputCommands
|
||||
|
||||
const INPUT_TIMEOUT := 30.0
|
||||
|
||||
var _input_map_result: Dictionary = {}
|
||||
var _input_map_pending: bool = false
|
||||
|
||||
var _sequence_result: Dictionary = {}
|
||||
var _sequence_pending: bool = false
|
||||
|
||||
|
||||
var _type_text_result: Dictionary = {}
|
||||
var _type_text_pending: bool = false
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_input_map": get_input_map,
|
||||
"execute_input_sequence": execute_input_sequence,
|
||||
"type_text": type_text,
|
||||
}
|
||||
|
||||
|
||||
func get_input_map(_params: Dictionary) -> Dictionary:
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return _get_editor_input_map()
|
||||
|
||||
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
||||
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
||||
return _get_editor_input_map()
|
||||
|
||||
_input_map_pending = true
|
||||
_input_map_result = {}
|
||||
|
||||
debugger_plugin.input_map_received.connect(_on_input_map_received, CONNECT_ONE_SHOT)
|
||||
debugger_plugin.request_input_map()
|
||||
|
||||
var start_time := Time.get_ticks_msec()
|
||||
while _input_map_pending:
|
||||
await Engine.get_main_loop().process_frame
|
||||
if (Time.get_ticks_msec() - start_time) / 1000.0 > INPUT_TIMEOUT:
|
||||
_input_map_pending = false
|
||||
if debugger_plugin.input_map_received.is_connected(_on_input_map_received):
|
||||
debugger_plugin.input_map_received.disconnect(_on_input_map_received)
|
||||
return _get_editor_input_map()
|
||||
|
||||
return _success(_input_map_result)
|
||||
|
||||
|
||||
func _get_editor_input_map() -> Dictionary:
|
||||
var actions: Array[Dictionary] = []
|
||||
for action_name in InputMap.get_actions():
|
||||
if action_name.begins_with("ui_"):
|
||||
continue
|
||||
var events := InputMap.action_get_events(action_name)
|
||||
var event_strings: Array[String] = []
|
||||
for event in events:
|
||||
event_strings.append(_event_to_string(event))
|
||||
actions.append({
|
||||
"name": action_name,
|
||||
"events": event_strings,
|
||||
})
|
||||
return _success({"actions": actions, "source": "editor"})
|
||||
|
||||
|
||||
func _event_to_string(event: InputEvent) -> String:
|
||||
if event is InputEventKey:
|
||||
var key_event := event as InputEventKey
|
||||
var key_name := OS.get_keycode_string(key_event.keycode)
|
||||
if key_event.ctrl_pressed:
|
||||
key_name = "Ctrl+" + key_name
|
||||
if key_event.alt_pressed:
|
||||
key_name = "Alt+" + key_name
|
||||
if key_event.shift_pressed:
|
||||
key_name = "Shift+" + key_name
|
||||
return key_name
|
||||
elif event is InputEventMouseButton:
|
||||
var mouse_event := event as InputEventMouseButton
|
||||
match mouse_event.button_index:
|
||||
MOUSE_BUTTON_LEFT:
|
||||
return "Mouse Left"
|
||||
MOUSE_BUTTON_RIGHT:
|
||||
return "Mouse Right"
|
||||
MOUSE_BUTTON_MIDDLE:
|
||||
return "Mouse Middle"
|
||||
_:
|
||||
return "Mouse Button %d" % mouse_event.button_index
|
||||
elif event is InputEventJoypadButton:
|
||||
var joy_event := event as InputEventJoypadButton
|
||||
return "Joypad Button %d" % joy_event.button_index
|
||||
elif event is InputEventJoypadMotion:
|
||||
var joy_motion := event as InputEventJoypadMotion
|
||||
return "Joypad Axis %d" % joy_motion.axis
|
||||
return event.as_text()
|
||||
|
||||
|
||||
func _on_input_map_received(actions: Array, error: String) -> void:
|
||||
_input_map_pending = false
|
||||
if error.is_empty():
|
||||
_input_map_result = {"actions": actions, "source": "game"}
|
||||
else:
|
||||
_input_map_result = {"error": error}
|
||||
|
||||
|
||||
func execute_input_sequence(params: Dictionary) -> Dictionary:
|
||||
var inputs: Array = params.get("inputs", [])
|
||||
if inputs.is_empty():
|
||||
return _error("INVALID_PARAMS", "inputs array is required and must not be empty")
|
||||
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return _error("NOT_RUNNING", "No game is currently running")
|
||||
|
||||
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
||||
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
||||
return _error("NO_SESSION", "No active debug session")
|
||||
|
||||
var max_end_time: float = 0.0
|
||||
for input in inputs:
|
||||
var start_ms: float = input.get("start_ms", 0.0)
|
||||
var duration_ms: float = input.get("duration_ms", 0.0)
|
||||
max_end_time = max(max_end_time, start_ms + duration_ms)
|
||||
|
||||
var timeout := max(INPUT_TIMEOUT, (max_end_time / 1000.0) + 5.0)
|
||||
|
||||
_sequence_pending = true
|
||||
_sequence_result = {}
|
||||
|
||||
debugger_plugin.input_sequence_completed.connect(_on_sequence_completed, CONNECT_ONE_SHOT)
|
||||
debugger_plugin.request_input_sequence(inputs)
|
||||
|
||||
var start_time := Time.get_ticks_msec()
|
||||
while _sequence_pending:
|
||||
await Engine.get_main_loop().process_frame
|
||||
if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
|
||||
_sequence_pending = false
|
||||
if debugger_plugin.input_sequence_completed.is_connected(_on_sequence_completed):
|
||||
debugger_plugin.input_sequence_completed.disconnect(_on_sequence_completed)
|
||||
return _error("TIMEOUT", "Timed out waiting for input sequence to complete")
|
||||
|
||||
if _sequence_result.has("error"):
|
||||
return _error("SEQUENCE_ERROR", _sequence_result.get("error", "Unknown error"))
|
||||
|
||||
return _success(_sequence_result)
|
||||
|
||||
|
||||
func _on_sequence_completed(result: Dictionary) -> void:
|
||||
_sequence_pending = false
|
||||
_sequence_result = result
|
||||
|
||||
|
||||
func type_text(params: Dictionary) -> Dictionary:
|
||||
var text: String = params.get("text", "")
|
||||
var delay_ms: int = int(params.get("delay_ms", 50))
|
||||
var submit: bool = params.get("submit", false)
|
||||
|
||||
if text.is_empty():
|
||||
return _error("INVALID_PARAMS", "text is required and must not be empty")
|
||||
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return _error("NOT_RUNNING", "No game is currently running")
|
||||
|
||||
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
||||
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
||||
return _error("NO_SESSION", "No active debug session")
|
||||
|
||||
var timeout := max(INPUT_TIMEOUT, (text.length() * delay_ms / 1000.0) + 5.0)
|
||||
|
||||
_type_text_pending = true
|
||||
_type_text_result = {}
|
||||
|
||||
debugger_plugin.type_text_completed.connect(_on_type_text_completed, CONNECT_ONE_SHOT)
|
||||
debugger_plugin.request_type_text(text, delay_ms, submit)
|
||||
|
||||
var start_time := Time.get_ticks_msec()
|
||||
while _type_text_pending:
|
||||
await Engine.get_main_loop().process_frame
|
||||
if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
|
||||
_type_text_pending = false
|
||||
if debugger_plugin.type_text_completed.is_connected(_on_type_text_completed):
|
||||
debugger_plugin.type_text_completed.disconnect(_on_type_text_completed)
|
||||
return _error("TIMEOUT", "Timed out waiting for text input to complete")
|
||||
|
||||
if _type_text_result.has("error"):
|
||||
return _error("TYPE_TEXT_ERROR", _type_text_result.get("error", "Unknown error"))
|
||||
|
||||
return _success(_type_text_result)
|
||||
|
||||
|
||||
func _on_type_text_completed(result: Dictionary) -> void:
|
||||
_type_text_pending = false
|
||||
_type_text_result = result
|
||||
@@ -0,0 +1 @@
|
||||
uid://doirdhuupjsk
|
||||
302
ruf-der-pilze/addons/godot_mcp/commands/node_commands.gd
Normal file
302
ruf-der-pilze/addons/godot_mcp/commands/node_commands.gd
Normal file
@@ -0,0 +1,302 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPNodeCommands
|
||||
|
||||
const FIND_NODES_TIMEOUT := 5.0
|
||||
|
||||
var _find_nodes_pending := false
|
||||
var _find_nodes_result: Dictionary = {}
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_node_properties": get_node_properties,
|
||||
"find_nodes": find_nodes,
|
||||
"create_node": create_node,
|
||||
"update_node": update_node,
|
||||
"delete_node": delete_node,
|
||||
"reparent_node": reparent_node,
|
||||
"connect_signal": connect_signal
|
||||
}
|
||||
|
||||
|
||||
func get_node_properties(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
|
||||
var properties := {}
|
||||
for prop in node.get_property_list():
|
||||
var name: String = prop["name"]
|
||||
if name.begins_with("_") or prop["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE == 0:
|
||||
if prop["usage"] & PROPERTY_USAGE_EDITOR == 0:
|
||||
continue
|
||||
|
||||
var value = node.get(name)
|
||||
properties[name] = _serialize_value(value)
|
||||
|
||||
return _success({"properties": properties})
|
||||
|
||||
|
||||
func find_nodes(params: Dictionary) -> Dictionary:
|
||||
var name_pattern: String = params.get("name_pattern", "")
|
||||
var type_filter: String = params.get("type", "")
|
||||
var root_path: String = params.get("root_path", "")
|
||||
|
||||
if name_pattern.is_empty() and type_filter.is_empty():
|
||||
return _error("INVALID_PARAMS", "At least one of name_pattern or type is required")
|
||||
|
||||
var debugger := _plugin.get_debugger_plugin() as MCPDebuggerPlugin
|
||||
if debugger and EditorInterface.is_playing_scene() and debugger.has_active_session():
|
||||
return await _find_nodes_via_game(debugger, name_pattern, type_filter, root_path)
|
||||
|
||||
var scene_check := _require_scene_open()
|
||||
if not scene_check.is_empty():
|
||||
return scene_check
|
||||
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var search_root: Node = scene_root
|
||||
|
||||
if not root_path.is_empty():
|
||||
search_root = _get_node(root_path)
|
||||
if not search_root:
|
||||
return _error("NODE_NOT_FOUND", "Root node not found: %s" % root_path)
|
||||
|
||||
var matches: Array[Dictionary] = []
|
||||
_find_recursive(search_root, scene_root, name_pattern, type_filter, matches)
|
||||
|
||||
return _success({"matches": matches, "count": matches.size()})
|
||||
|
||||
|
||||
func _find_nodes_via_game(debugger: MCPDebuggerPlugin, name_pattern: String, type_filter: String, root_path: String) -> Dictionary:
|
||||
_find_nodes_pending = true
|
||||
_find_nodes_result = {}
|
||||
|
||||
if debugger.find_nodes_received.is_connected(_on_find_nodes_received):
|
||||
debugger.find_nodes_received.disconnect(_on_find_nodes_received)
|
||||
debugger.find_nodes_received.connect(_on_find_nodes_received, CONNECT_ONE_SHOT)
|
||||
debugger.request_find_nodes(name_pattern, type_filter, root_path)
|
||||
|
||||
var start_time := Time.get_ticks_msec()
|
||||
while _find_nodes_pending:
|
||||
await Engine.get_main_loop().process_frame
|
||||
if (Time.get_ticks_msec() - start_time) / 1000.0 > FIND_NODES_TIMEOUT:
|
||||
_find_nodes_pending = false
|
||||
if debugger.find_nodes_received.is_connected(_on_find_nodes_received):
|
||||
debugger.find_nodes_received.disconnect(_on_find_nodes_received)
|
||||
return _error("TIMEOUT", "Game did not respond within %d seconds" % int(FIND_NODES_TIMEOUT))
|
||||
|
||||
return _find_nodes_result
|
||||
|
||||
|
||||
func _on_find_nodes_received(matches: Array, count: int, error: String) -> void:
|
||||
_find_nodes_pending = false
|
||||
if not error.is_empty():
|
||||
_find_nodes_result = _error("GAME_ERROR", error)
|
||||
else:
|
||||
_find_nodes_result = _success({"matches": matches, "count": count})
|
||||
|
||||
|
||||
func _find_recursive(node: Node, scene_root: Node, name_pattern: String, type_filter: String, results: Array[Dictionary]) -> void:
|
||||
var name_matches := name_pattern.is_empty() or node.name.matchn(name_pattern)
|
||||
var type_matches := type_filter.is_empty() or node.is_class(type_filter)
|
||||
|
||||
if name_matches and type_matches:
|
||||
var relative_path := scene_root.get_path_to(node)
|
||||
var usable_path := "/root/" + scene_root.name
|
||||
if relative_path != NodePath("."):
|
||||
usable_path += "/" + str(relative_path)
|
||||
|
||||
results.append({
|
||||
"path": usable_path,
|
||||
"type": node.get_class()
|
||||
})
|
||||
|
||||
for child in node.get_children():
|
||||
_find_recursive(child, scene_root, name_pattern, type_filter, results)
|
||||
|
||||
|
||||
func create_node(params: Dictionary) -> Dictionary:
|
||||
var scene_check := _require_scene_open()
|
||||
if not scene_check.is_empty():
|
||||
return scene_check
|
||||
|
||||
var parent_path: String = params.get("parent_path", "")
|
||||
var node_type: String = params.get("node_type", "")
|
||||
var scene_path: String = params.get("scene_path", "")
|
||||
var node_name: String = params.get("node_name", "")
|
||||
var properties: Dictionary = params.get("properties", {})
|
||||
|
||||
if parent_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "parent_path is required")
|
||||
if node_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_name is required")
|
||||
if node_type.is_empty() and scene_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "Either node_type or scene_path is required")
|
||||
if not node_type.is_empty() and not scene_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "Provide node_type OR scene_path, not both")
|
||||
|
||||
var parent := _get_node(parent_path)
|
||||
if not parent:
|
||||
return _error("NODE_NOT_FOUND", "Parent node not found: %s" % parent_path)
|
||||
|
||||
var node: Node
|
||||
if not scene_path.is_empty():
|
||||
if not ResourceLoader.exists(scene_path):
|
||||
return _error("SCENE_NOT_FOUND", "Scene not found: %s" % scene_path)
|
||||
var packed_scene: PackedScene = load(scene_path)
|
||||
if not packed_scene:
|
||||
return _error("LOAD_FAILED", "Failed to load scene: %s" % scene_path)
|
||||
node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
|
||||
if not node:
|
||||
return _error("INSTANTIATE_FAILED", "Failed to instantiate: %s" % scene_path)
|
||||
else:
|
||||
if not ClassDB.class_exists(node_type):
|
||||
return _error("INVALID_TYPE", "Unknown node type: %s" % node_type)
|
||||
node = ClassDB.instantiate(node_type)
|
||||
if not node:
|
||||
return _error("CREATE_FAILED", "Failed to create node of type: %s" % node_type)
|
||||
|
||||
node.name = node_name
|
||||
|
||||
for key in properties:
|
||||
if node.has_method("set") and key in node:
|
||||
var deserialized := MCPUtils.deserialize_value(properties[key])
|
||||
node.set(key, deserialized)
|
||||
|
||||
parent.add_child(node)
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
_set_owner_recursive(node, scene_root)
|
||||
|
||||
return _success({"node_path": str(scene_root.get_path_to(node))})
|
||||
|
||||
|
||||
func _set_owner_recursive(node: Node, owner: Node) -> void:
|
||||
node.owner = owner
|
||||
for child in node.get_children():
|
||||
_set_owner_recursive(child, owner)
|
||||
|
||||
|
||||
func update_node(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var properties: Dictionary = params.get("properties", {})
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if properties.is_empty():
|
||||
return _error("INVALID_PARAMS", "properties is required")
|
||||
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
|
||||
for key in properties:
|
||||
if key in node:
|
||||
var deserialized := MCPUtils.deserialize_value(properties[key])
|
||||
node.set(key, deserialized)
|
||||
|
||||
return _success({})
|
||||
|
||||
|
||||
func delete_node(params: Dictionary) -> Dictionary:
|
||||
var scene_check := _require_scene_open()
|
||||
if not scene_check.is_empty():
|
||||
return scene_check
|
||||
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if node == root:
|
||||
return _error("CANNOT_DELETE_ROOT", "Cannot delete the root node")
|
||||
|
||||
node.get_parent().remove_child(node)
|
||||
node.queue_free()
|
||||
|
||||
return _success({})
|
||||
|
||||
|
||||
func reparent_node(params: Dictionary) -> Dictionary:
|
||||
var scene_check := _require_scene_open()
|
||||
if not scene_check.is_empty():
|
||||
return scene_check
|
||||
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var new_parent_path: String = params.get("new_parent_path", "")
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if new_parent_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "new_parent_path is required")
|
||||
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
|
||||
var new_parent := _get_node(new_parent_path)
|
||||
if not new_parent:
|
||||
return _error("NODE_NOT_FOUND", "New parent not found: %s" % new_parent_path)
|
||||
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if node == root:
|
||||
return _error("CANNOT_REPARENT_ROOT", "Cannot reparent the root node")
|
||||
|
||||
if new_parent == node or node.is_ancestor_of(new_parent):
|
||||
return _error("INVALID_REPARENT", "Cannot reparent a node to itself or its descendant")
|
||||
|
||||
node.reparent(new_parent)
|
||||
|
||||
return _success({"new_path": str(root.get_path_to(node))})
|
||||
|
||||
|
||||
func connect_signal(params: Dictionary) -> Dictionary:
|
||||
var scene_check := _require_scene_open()
|
||||
if not scene_check.is_empty():
|
||||
return scene_check
|
||||
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var signal_name: String = params.get("signal_name", "")
|
||||
var target_path: String = params.get("target_path", "")
|
||||
var method_name: String = params.get("method_name", "")
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if signal_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "signal_name is required")
|
||||
if target_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "target_path is required")
|
||||
if method_name.is_empty():
|
||||
return _error("INVALID_PARAMS", "method_name is required")
|
||||
|
||||
var source_node := _get_node(node_path)
|
||||
if not source_node:
|
||||
return _error("NODE_NOT_FOUND", "Source node not found: %s" % node_path)
|
||||
|
||||
var target_node := _get_node(target_path)
|
||||
if not target_node:
|
||||
return _error("NODE_NOT_FOUND", "Target node not found: %s" % target_path)
|
||||
|
||||
if not source_node.has_signal(signal_name):
|
||||
return _error("SIGNAL_NOT_FOUND", "Signal '%s' not found on node %s" % [signal_name, node_path])
|
||||
|
||||
if source_node.is_connected(signal_name, Callable(target_node, method_name)):
|
||||
return _error("ALREADY_CONNECTED", "Signal '%s' is already connected to %s.%s()" % [signal_name, target_path, method_name])
|
||||
|
||||
var err := source_node.connect(signal_name, Callable(target_node, method_name), CONNECT_PERSIST)
|
||||
if err != OK:
|
||||
return _error("CONNECT_FAILED", "Failed to connect signal: %s" % error_string(err))
|
||||
|
||||
EditorInterface.mark_scene_as_unsaved()
|
||||
|
||||
return _success({})
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://hahpgho4qb0b
|
||||
143
ruf-der-pilze/addons/godot_mcp/commands/profiler_commands.gd
Normal file
143
ruf-der-pilze/addons/godot_mcp/commands/profiler_commands.gd
Normal file
@@ -0,0 +1,143 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPProfilerCommands
|
||||
|
||||
const PROFILER_TIMEOUT := 5.0
|
||||
const GENERIC_TIMEOUT := 5.0
|
||||
|
||||
var _performance_metrics_pending: bool = false
|
||||
var _performance_metrics_result: Dictionary = {}
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_performance_metrics": get_performance_metrics,
|
||||
"start_profiler": start_profiler,
|
||||
"stop_profiler": stop_profiler,
|
||||
"get_profiler_data": get_profiler_data,
|
||||
"get_active_processes": get_active_processes,
|
||||
"get_signal_connections": get_signal_connections,
|
||||
}
|
||||
|
||||
|
||||
func get_performance_metrics(_params: Dictionary) -> Dictionary:
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return _error("NOT_RUNNING", "No game is currently running")
|
||||
|
||||
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
||||
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
||||
return _error("NO_SESSION", "No active debug session")
|
||||
|
||||
_performance_metrics_pending = true
|
||||
_performance_metrics_result = {}
|
||||
|
||||
debugger_plugin.performance_metrics_received.connect(_on_performance_metrics_received, CONNECT_ONE_SHOT)
|
||||
debugger_plugin.request_performance_metrics()
|
||||
|
||||
var start_time := Time.get_ticks_msec()
|
||||
while _performance_metrics_pending:
|
||||
await Engine.get_main_loop().process_frame
|
||||
if (Time.get_ticks_msec() - start_time) / 1000.0 > PROFILER_TIMEOUT:
|
||||
_performance_metrics_pending = false
|
||||
if debugger_plugin.performance_metrics_received.is_connected(_on_performance_metrics_received):
|
||||
debugger_plugin.performance_metrics_received.disconnect(_on_performance_metrics_received)
|
||||
return _error("TIMEOUT", "Timed out waiting for performance metrics")
|
||||
|
||||
return _success(_performance_metrics_result)
|
||||
|
||||
|
||||
func _on_performance_metrics_received(metrics: Dictionary) -> void:
|
||||
_performance_metrics_pending = false
|
||||
_performance_metrics_result = metrics
|
||||
|
||||
|
||||
func start_profiler(_params: Dictionary) -> Dictionary:
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return _error("NOT_RUNNING", "No game is currently running")
|
||||
|
||||
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
||||
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
||||
return _error("NO_SESSION", "No active debug session")
|
||||
|
||||
debugger_plugin.toggle_frame_profiler(true)
|
||||
return _success({"message": "Frame profiler started"})
|
||||
|
||||
|
||||
func stop_profiler(_params: Dictionary) -> Dictionary:
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return _error("NOT_RUNNING", "No game is currently running")
|
||||
|
||||
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
||||
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
||||
return _error("NO_SESSION", "No active debug session")
|
||||
|
||||
debugger_plugin.toggle_frame_profiler(false)
|
||||
return _success({"message": "Frame profiler stopped"})
|
||||
|
||||
|
||||
func get_profiler_data(_params: Dictionary) -> Dictionary:
|
||||
var result = await _send_and_wait("get_profiler_data")
|
||||
if result == null:
|
||||
return _last_error
|
||||
var result_dict: Dictionary
|
||||
if result is Dictionary:
|
||||
result_dict = result
|
||||
else:
|
||||
result_dict = {"data": result}
|
||||
return _success(result_dict)
|
||||
|
||||
|
||||
func get_active_processes(_params: Dictionary) -> Dictionary:
|
||||
var result = await _send_and_wait("get_active_processes")
|
||||
if result == null:
|
||||
return _last_error
|
||||
var result_dict: Dictionary
|
||||
if result is Dictionary:
|
||||
result_dict = result
|
||||
else:
|
||||
result_dict = {"data": result}
|
||||
return _success(result_dict)
|
||||
|
||||
|
||||
func get_signal_connections(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var result = await _send_and_wait("get_signal_connections", [node_path])
|
||||
if result == null:
|
||||
return _last_error
|
||||
var result_dict: Dictionary
|
||||
if result is Dictionary:
|
||||
result_dict = result
|
||||
else:
|
||||
result_dict = {"data": result}
|
||||
return _success(result_dict)
|
||||
|
||||
|
||||
var _last_error: Dictionary = {}
|
||||
|
||||
|
||||
func _send_and_wait(msg_type: String, args: Array = []):
|
||||
if not EditorInterface.is_playing_scene():
|
||||
_last_error = _error("NOT_RUNNING", "No game is currently running")
|
||||
return null
|
||||
|
||||
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
||||
if debugger_plugin == null or not debugger_plugin.has_active_session():
|
||||
_last_error = _error("NO_SESSION", "No active debug session")
|
||||
return null
|
||||
|
||||
var sent: bool = debugger_plugin.send_game_message(msg_type, args)
|
||||
if not sent:
|
||||
_last_error = _error("SEND_FAILED", "Failed to send message to game")
|
||||
return null
|
||||
|
||||
var start_time := Time.get_ticks_msec()
|
||||
while not debugger_plugin.has_response(msg_type):
|
||||
await Engine.get_main_loop().process_frame
|
||||
if (Time.get_ticks_msec() - start_time) / 1000.0 > GENERIC_TIMEOUT:
|
||||
debugger_plugin.clear_response(msg_type)
|
||||
_last_error = _error("TIMEOUT", "Timed out waiting for %s response" % msg_type)
|
||||
return null
|
||||
|
||||
var response = debugger_plugin.get_response(msg_type)
|
||||
debugger_plugin.clear_response(msg_type)
|
||||
return response
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3b5hmxyxcbsg
|
||||
114
ruf-der-pilze/addons/godot_mcp/commands/project_commands.gd
Normal file
114
ruf-der-pilze/addons/godot_mcp/commands/project_commands.gd
Normal file
@@ -0,0 +1,114 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPProjectCommands
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_project_info": get_project_info,
|
||||
"get_project_settings": get_project_settings
|
||||
}
|
||||
|
||||
|
||||
func get_project_info(_params: Dictionary) -> Dictionary:
|
||||
return _success({
|
||||
"name": ProjectSettings.get_setting("application/config/name", "Unknown"),
|
||||
"path": ProjectSettings.globalize_path("res://"),
|
||||
"godot_version": Engine.get_version_info()["string"],
|
||||
"main_scene": ProjectSettings.get_setting("application/run/main_scene", null)
|
||||
})
|
||||
|
||||
|
||||
func get_project_settings(params: Dictionary) -> Dictionary:
|
||||
var category: String = params.get("category", "")
|
||||
|
||||
if category == "input":
|
||||
return _get_input_mappings(params)
|
||||
|
||||
var settings := {}
|
||||
var all_settings := ProjectSettings.get_property_list()
|
||||
|
||||
for prop in all_settings:
|
||||
var name: String = prop["name"]
|
||||
if not category.is_empty() and not name.begins_with(category):
|
||||
continue
|
||||
if prop["usage"] & PROPERTY_USAGE_EDITOR:
|
||||
settings[name] = _serialize_value(ProjectSettings.get_setting(name))
|
||||
|
||||
return _success({"settings": settings})
|
||||
|
||||
|
||||
func _get_input_mappings(params: Dictionary) -> Dictionary:
|
||||
var include_builtin: bool = params.get("include_builtin", false)
|
||||
var actions := {}
|
||||
|
||||
# Read from ProjectSettings instead of InputMap
|
||||
# InputMap in editor context only has editor actions, not game inputs
|
||||
# Game inputs are stored as "input/<action_name>" in ProjectSettings
|
||||
var all_settings := ProjectSettings.get_property_list()
|
||||
|
||||
for prop in all_settings:
|
||||
var name: String = prop["name"]
|
||||
if not name.begins_with("input/"):
|
||||
continue
|
||||
|
||||
var action_name := name.substr(6) # Remove "input/" prefix
|
||||
|
||||
if not include_builtin and action_name.begins_with("ui_"):
|
||||
continue
|
||||
|
||||
var action_data = ProjectSettings.get_setting(name)
|
||||
if action_data is Dictionary:
|
||||
var events := []
|
||||
var raw_events = action_data.get("events", [])
|
||||
for event in raw_events:
|
||||
if event is InputEvent:
|
||||
events.append(_serialize_input_event(event))
|
||||
|
||||
actions[action_name] = {
|
||||
"deadzone": action_data.get("deadzone", 0.5),
|
||||
"events": events
|
||||
}
|
||||
|
||||
return _success({"settings": actions})
|
||||
|
||||
|
||||
func _serialize_input_event(event: InputEvent) -> Dictionary:
|
||||
if event is InputEventKey:
|
||||
var keycode: int = event.keycode if event.keycode else event.physical_keycode
|
||||
return {
|
||||
"type": "key",
|
||||
"keycode": event.keycode,
|
||||
"physical_keycode": event.physical_keycode,
|
||||
"key_label": OS.get_keycode_string(keycode),
|
||||
"modifiers": _get_modifiers(event)
|
||||
}
|
||||
elif event is InputEventMouseButton:
|
||||
return {
|
||||
"type": "mouse_button",
|
||||
"button_index": event.button_index,
|
||||
"modifiers": _get_modifiers(event)
|
||||
}
|
||||
elif event is InputEventJoypadButton:
|
||||
return {
|
||||
"type": "joypad_button",
|
||||
"button_index": event.button_index,
|
||||
"device": event.device
|
||||
}
|
||||
elif event is InputEventJoypadMotion:
|
||||
return {
|
||||
"type": "joypad_motion",
|
||||
"axis": event.axis,
|
||||
"axis_value": event.axis_value,
|
||||
"device": event.device
|
||||
}
|
||||
return {"type": "unknown", "event": str(event)}
|
||||
|
||||
|
||||
func _get_modifiers(event: InputEventWithModifiers) -> Dictionary:
|
||||
return {
|
||||
"shift": event.shift_pressed,
|
||||
"ctrl": event.ctrl_pressed,
|
||||
"alt": event.alt_pressed,
|
||||
"meta": event.meta_pressed
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dn711qsn11x65
|
||||
293
ruf-der-pilze/addons/godot_mcp/commands/resource_commands.gd
Normal file
293
ruf-der-pilze/addons/godot_mcp/commands/resource_commands.gd
Normal file
@@ -0,0 +1,293 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPResourceCommands
|
||||
|
||||
|
||||
const MAX_ARRAY_PREVIEW := 100
|
||||
const BINARY_ARRAY_TYPES := [
|
||||
TYPE_PACKED_BYTE_ARRAY,
|
||||
TYPE_PACKED_INT32_ARRAY,
|
||||
TYPE_PACKED_INT64_ARRAY,
|
||||
TYPE_PACKED_FLOAT32_ARRAY,
|
||||
TYPE_PACKED_FLOAT64_ARRAY,
|
||||
]
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_resource_info": get_resource_info
|
||||
}
|
||||
|
||||
|
||||
func get_resource_info(params: Dictionary) -> Dictionary:
|
||||
var resource_path: String = params.get("resource_path", "")
|
||||
var max_depth: int = params.get("max_depth", 1)
|
||||
var include_internal: bool = params.get("include_internal", false)
|
||||
|
||||
if resource_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "resource_path is required")
|
||||
|
||||
if not ResourceLoader.exists(resource_path):
|
||||
return _error("RESOURCE_NOT_FOUND", "Resource not found: %s" % resource_path)
|
||||
|
||||
var resource := load(resource_path)
|
||||
if not resource:
|
||||
return _error("LOAD_FAILED", "Failed to load resource: %s" % resource_path)
|
||||
|
||||
var result := {
|
||||
"resource_path": resource_path,
|
||||
"resource_type": resource.get_class()
|
||||
}
|
||||
|
||||
var type_specific := _get_type_specific_info(resource, max_depth)
|
||||
if not type_specific.is_empty():
|
||||
result["type_specific"] = type_specific
|
||||
else:
|
||||
var properties := _get_generic_properties(resource, max_depth, include_internal)
|
||||
if not properties.is_empty():
|
||||
result["properties"] = properties
|
||||
|
||||
return _success(result)
|
||||
|
||||
|
||||
func _get_type_specific_info(resource: Resource, max_depth: int) -> Dictionary:
|
||||
if resource is SpriteFrames:
|
||||
return _format_sprite_frames(resource, max_depth)
|
||||
elif resource is TileSet:
|
||||
return _format_tileset(resource, max_depth)
|
||||
elif resource is ShaderMaterial:
|
||||
return _format_shader_material(resource, max_depth)
|
||||
elif resource is StandardMaterial3D or resource is ORMMaterial3D:
|
||||
return _format_standard_material(resource, max_depth)
|
||||
elif resource is Texture2D:
|
||||
return _format_texture2d(resource)
|
||||
return {}
|
||||
|
||||
|
||||
func _format_sprite_frames(sf: SpriteFrames, max_depth: int) -> Dictionary:
|
||||
var animations := []
|
||||
|
||||
for anim_name in sf.get_animation_names():
|
||||
var anim_info := {
|
||||
"name": str(anim_name),
|
||||
"frame_count": sf.get_frame_count(anim_name),
|
||||
"fps": sf.get_animation_speed(anim_name),
|
||||
"loop": sf.get_animation_loop(anim_name),
|
||||
}
|
||||
|
||||
if max_depth >= 1:
|
||||
var frames := []
|
||||
for i in range(sf.get_frame_count(anim_name)):
|
||||
var texture := sf.get_frame_texture(anim_name, i)
|
||||
var frame_info := {
|
||||
"index": i,
|
||||
"duration": sf.get_frame_duration(anim_name, i),
|
||||
}
|
||||
|
||||
if texture:
|
||||
frame_info["texture_type"] = texture.get_class()
|
||||
|
||||
if texture is AtlasTexture:
|
||||
var atlas := texture as AtlasTexture
|
||||
if atlas.atlas:
|
||||
frame_info["atlas_source"] = atlas.atlas.resource_path
|
||||
frame_info["region"] = _serialize_rect2(atlas.region)
|
||||
if atlas.margin != Rect2():
|
||||
frame_info["margin"] = _serialize_rect2(atlas.margin)
|
||||
elif texture.resource_path:
|
||||
frame_info["texture_path"] = texture.resource_path
|
||||
|
||||
frames.append(frame_info)
|
||||
anim_info["frames"] = frames
|
||||
|
||||
animations.append(anim_info)
|
||||
|
||||
return {"animations": animations}
|
||||
|
||||
|
||||
func _format_tileset(ts: TileSet, max_depth: int) -> Dictionary:
|
||||
var sources := []
|
||||
|
||||
for i in range(ts.get_source_count()):
|
||||
var source_id := ts.get_source_id(i)
|
||||
var source := ts.get_source(source_id)
|
||||
var source_info := {
|
||||
"source_id": source_id,
|
||||
"source_type": source.get_class(),
|
||||
}
|
||||
|
||||
if source is TileSetAtlasSource:
|
||||
var atlas := source as TileSetAtlasSource
|
||||
if atlas.texture:
|
||||
source_info["texture_path"] = atlas.texture.resource_path
|
||||
source_info["texture_region_size"] = _serialize_vector2i(atlas.texture_region_size)
|
||||
source_info["tile_count"] = atlas.get_tiles_count()
|
||||
|
||||
if max_depth >= 2:
|
||||
var tiles := []
|
||||
for j in range(atlas.get_tiles_count()):
|
||||
var coords := atlas.get_tile_id(j)
|
||||
tiles.append({
|
||||
"atlas_coords": _serialize_vector2i(coords),
|
||||
"size": _serialize_vector2i(atlas.get_tile_size_in_atlas(coords))
|
||||
})
|
||||
source_info["tiles"] = _truncate_array(tiles)
|
||||
|
||||
elif source is TileSetScenesCollectionSource:
|
||||
var scenes := source as TileSetScenesCollectionSource
|
||||
source_info["scene_count"] = scenes.get_scene_tiles_count()
|
||||
|
||||
sources.append(source_info)
|
||||
|
||||
return {
|
||||
"tile_size": _serialize_vector2i(ts.tile_size),
|
||||
"source_count": ts.get_source_count(),
|
||||
"physics_layers_count": ts.get_physics_layers_count(),
|
||||
"navigation_layers_count": ts.get_navigation_layers_count(),
|
||||
"custom_data_layers_count": ts.get_custom_data_layers_count(),
|
||||
"terrain_sets_count": ts.get_terrain_sets_count(),
|
||||
"sources": sources
|
||||
}
|
||||
|
||||
|
||||
func _format_shader_material(mat: ShaderMaterial, max_depth: int) -> Dictionary:
|
||||
var result := {
|
||||
"shader_path": mat.shader.resource_path if mat.shader else ""
|
||||
}
|
||||
|
||||
if mat.shader and max_depth >= 1:
|
||||
var params := {}
|
||||
for prop in mat.get_property_list():
|
||||
var prop_name: String = prop["name"]
|
||||
if prop_name.begins_with("shader_parameter/"):
|
||||
var param_name := prop_name.substr(len("shader_parameter/"))
|
||||
var value = mat.get_shader_parameter(param_name)
|
||||
params[param_name] = _serialize_property_value(value, max_depth - 1)
|
||||
if not params.is_empty():
|
||||
result["shader_parameters"] = params
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func _format_standard_material(mat: BaseMaterial3D, max_depth: int) -> Dictionary:
|
||||
var result := {
|
||||
"albedo_color": _serialize_color(mat.albedo_color),
|
||||
"metallic": mat.metallic,
|
||||
"roughness": mat.roughness,
|
||||
"emission_enabled": mat.emission_enabled,
|
||||
"transparency": mat.transparency,
|
||||
"cull_mode": mat.cull_mode,
|
||||
"shading_mode": mat.shading_mode
|
||||
}
|
||||
|
||||
if mat.albedo_texture:
|
||||
result["albedo_texture"] = mat.albedo_texture.resource_path
|
||||
|
||||
if mat.emission_enabled and mat.emission_texture:
|
||||
result["emission_texture"] = mat.emission_texture.resource_path
|
||||
|
||||
if mat.normal_enabled and mat.normal_texture:
|
||||
result["normal_texture"] = mat.normal_texture.resource_path
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func _format_texture2d(tex: Texture2D) -> Dictionary:
|
||||
var result := {
|
||||
"width": tex.get_width(),
|
||||
"height": tex.get_height(),
|
||||
"texture_type": tex.get_class()
|
||||
}
|
||||
|
||||
if tex is CompressedTexture2D:
|
||||
var ct := tex as CompressedTexture2D
|
||||
result["load_path"] = ct.load_path
|
||||
|
||||
if tex is AtlasTexture:
|
||||
var at := tex as AtlasTexture
|
||||
if at.atlas:
|
||||
result["atlas_source"] = at.atlas.resource_path
|
||||
result["region"] = _serialize_rect2(at.region)
|
||||
if at.margin != Rect2():
|
||||
result["margin"] = _serialize_rect2(at.margin)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func _get_generic_properties(resource: Resource, max_depth: int, include_internal: bool) -> Dictionary:
|
||||
var properties := {}
|
||||
|
||||
for prop in resource.get_property_list():
|
||||
var prop_name: String = prop["name"]
|
||||
|
||||
if prop_name.begins_with("_") and not include_internal:
|
||||
continue
|
||||
if prop["usage"] & PROPERTY_USAGE_EDITOR == 0:
|
||||
continue
|
||||
if prop_name in ["resource_local_to_scene", "resource_path", "resource_name", "script"]:
|
||||
continue
|
||||
|
||||
var value = resource.get(prop_name)
|
||||
properties[prop_name] = _serialize_property_value(value, max_depth)
|
||||
|
||||
return properties
|
||||
|
||||
|
||||
func _serialize_property_value(value: Variant, depth: int) -> Variant:
|
||||
var value_type := typeof(value)
|
||||
|
||||
if value_type in BINARY_ARRAY_TYPES:
|
||||
return {"_binary_array": true, "size": value.size(), "type": type_string(value_type)}
|
||||
|
||||
if value_type == TYPE_ARRAY:
|
||||
if value.size() > MAX_ARRAY_PREVIEW:
|
||||
var preview := []
|
||||
for i in range(MAX_ARRAY_PREVIEW):
|
||||
preview.append(_serialize_property_value(value[i], depth - 1) if depth > 0 else str(value[i]))
|
||||
return {"_truncated": true, "size": value.size(), "preview": preview}
|
||||
else:
|
||||
var result := []
|
||||
for item in value:
|
||||
result.append(_serialize_property_value(item, depth - 1) if depth > 0 else str(item))
|
||||
return result
|
||||
|
||||
if value_type == TYPE_DICTIONARY:
|
||||
var result := {}
|
||||
for key in value:
|
||||
result[str(key)] = _serialize_property_value(value[key], depth - 1) if depth > 0 else str(value[key])
|
||||
return result
|
||||
|
||||
if value_type == TYPE_OBJECT:
|
||||
if value == null:
|
||||
return null
|
||||
if value is Resource:
|
||||
if value.resource_path and not value.resource_path.is_empty():
|
||||
return {"_resource_ref": value.resource_path, "type": value.get_class()}
|
||||
elif depth > 0:
|
||||
return {"_inline_resource": true, "type": value.get_class()}
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
return _serialize_value(value)
|
||||
|
||||
|
||||
func _serialize_rect2(r: Rect2) -> Dictionary:
|
||||
return {"x": r.position.x, "y": r.position.y, "width": r.size.x, "height": r.size.y}
|
||||
|
||||
|
||||
func _serialize_vector2i(v: Vector2i) -> Dictionary:
|
||||
return {"x": v.x, "y": v.y}
|
||||
|
||||
|
||||
func _serialize_color(c: Color) -> Dictionary:
|
||||
return {"r": c.r, "g": c.g, "b": c.b, "a": c.a}
|
||||
|
||||
|
||||
func _truncate_array(arr: Array, limit: int = MAX_ARRAY_PREVIEW) -> Variant:
|
||||
if arr.size() <= limit:
|
||||
return arr
|
||||
return {
|
||||
"_truncated": true,
|
||||
"size": arr.size(),
|
||||
"preview": arr.slice(0, limit)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://lkiiwjdnyop4
|
||||
162
ruf-der-pilze/addons/godot_mcp/commands/scene3d_commands.gd
Normal file
162
ruf-der-pilze/addons/godot_mcp/commands/scene3d_commands.gd
Normal file
@@ -0,0 +1,162 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPScene3DCommands
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_spatial_info": get_spatial_info,
|
||||
"get_scene_bounds": get_scene_bounds,
|
||||
}
|
||||
|
||||
|
||||
func get_spatial_info(params: Dictionary) -> Dictionary:
|
||||
var scene_check := _require_scene_open()
|
||||
if not scene_check.is_empty():
|
||||
return scene_check
|
||||
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var include_children: bool = params.get("include_children", false)
|
||||
var type_filter: String = params.get("type_filter", "")
|
||||
var max_results: int = params.get("max_results", 0)
|
||||
var within_aabb: Dictionary = params.get("within_aabb", {})
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
|
||||
if not node is Node3D:
|
||||
return _error("NOT_NODE3D", "Node is not a Node3D: %s" % node_path)
|
||||
|
||||
var filter_aabb: AABB = AABB()
|
||||
var use_aabb_filter := false
|
||||
if not within_aabb.is_empty():
|
||||
var pos: Dictionary = within_aabb.get("position", {})
|
||||
var size: Dictionary = within_aabb.get("size", {})
|
||||
if pos.has("x") and size.has("x"):
|
||||
filter_aabb = AABB(
|
||||
Vector3(pos.get("x", 0), pos.get("y", 0), pos.get("z", 0)),
|
||||
Vector3(size.get("x", 0), size.get("y", 0), size.get("z", 0))
|
||||
)
|
||||
use_aabb_filter = true
|
||||
|
||||
var nodes: Array[Dictionary] = []
|
||||
var state := {"max": max_results, "count": 0, "stopped": false}
|
||||
_collect_spatial_info(node, nodes, type_filter, include_children, use_aabb_filter, filter_aabb, state)
|
||||
|
||||
var result := {"nodes": nodes, "count": nodes.size()}
|
||||
if state.stopped:
|
||||
result["truncated"] = true
|
||||
result["max_results"] = max_results
|
||||
return _success(result)
|
||||
|
||||
|
||||
func _collect_spatial_info(node: Node, results: Array[Dictionary], type_filter: String, include_children: bool, use_aabb_filter: bool, filter_aabb: AABB, state: Dictionary) -> void:
|
||||
if state.max > 0 and state.count >= state.max:
|
||||
state.stopped = true
|
||||
return
|
||||
|
||||
if node is Node3D:
|
||||
var node3d := node as Node3D
|
||||
var type_matches := type_filter.is_empty() or node.is_class(type_filter)
|
||||
var aabb_matches := true
|
||||
if use_aabb_filter:
|
||||
aabb_matches = filter_aabb.has_point(node3d.global_position)
|
||||
if type_matches and aabb_matches:
|
||||
results.append(_get_node3d_info(node3d))
|
||||
state.count += 1
|
||||
|
||||
if include_children and not state.stopped:
|
||||
for child in node.get_children():
|
||||
_collect_spatial_info(child, results, type_filter, true, use_aabb_filter, filter_aabb, state)
|
||||
if state.stopped:
|
||||
break
|
||||
|
||||
|
||||
func _get_node3d_info(node: Node3D) -> Dictionary:
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var relative_path := scene_root.get_path_to(node)
|
||||
var usable_path := "/root/" + scene_root.name
|
||||
if relative_path != NodePath("."):
|
||||
usable_path += "/" + str(relative_path)
|
||||
|
||||
var gpos: Vector3 = node.global_position
|
||||
var grot: Vector3 = node.global_rotation
|
||||
var gscale: Vector3 = node.global_transform.basis.get_scale()
|
||||
|
||||
var info := {
|
||||
"path": usable_path,
|
||||
"type": node.get_class(),
|
||||
"global_position": {"x": gpos.x, "y": gpos.y, "z": gpos.z},
|
||||
"global_rotation": {"x": grot.x, "y": grot.y, "z": grot.z},
|
||||
"global_scale": {"x": gscale.x, "y": gscale.y, "z": gscale.z},
|
||||
"visible": node.visible,
|
||||
}
|
||||
|
||||
if node is VisualInstance3D:
|
||||
var aabb := (node as VisualInstance3D).get_aabb()
|
||||
var global_aabb := node.global_transform * aabb
|
||||
info["aabb"] = _serialize_aabb(aabb)
|
||||
info["global_aabb"] = _serialize_aabb(global_aabb)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
func _serialize_aabb(aabb: AABB) -> Dictionary:
|
||||
return {
|
||||
"position": _serialize_value(aabb.position),
|
||||
"size": _serialize_value(aabb.size),
|
||||
"end": _serialize_value(aabb.end),
|
||||
}
|
||||
|
||||
|
||||
func get_scene_bounds(params: Dictionary) -> Dictionary:
|
||||
var scene_check := _require_scene_open()
|
||||
if not scene_check.is_empty():
|
||||
return scene_check
|
||||
|
||||
var root_path: String = params.get("root_path", "")
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
|
||||
var search_root: Node = scene_root
|
||||
if not root_path.is_empty():
|
||||
search_root = _get_node(root_path)
|
||||
if not search_root:
|
||||
return _error("NODE_NOT_FOUND", "Root node not found: %s" % root_path)
|
||||
|
||||
var state := {"aabb": AABB(), "count": 0, "first": true}
|
||||
_collect_bounds(search_root, state)
|
||||
|
||||
if state.count == 0:
|
||||
return _error("NO_GEOMETRY", "No VisualInstance3D nodes found under: %s" % (root_path if not root_path.is_empty() else "scene root"))
|
||||
|
||||
var usable_path := "/root/" + scene_root.name
|
||||
if search_root != scene_root:
|
||||
var relative_path := scene_root.get_path_to(search_root)
|
||||
usable_path += "/" + str(relative_path)
|
||||
|
||||
return _success({
|
||||
"root_path": usable_path,
|
||||
"node_count": state.count,
|
||||
"combined_aabb": _serialize_aabb(state.aabb),
|
||||
})
|
||||
|
||||
|
||||
func _collect_bounds(node: Node, state: Dictionary) -> void:
|
||||
if node is VisualInstance3D:
|
||||
var visual := node as VisualInstance3D
|
||||
var local_aabb := visual.get_aabb()
|
||||
var global_aabb := visual.global_transform * local_aabb
|
||||
|
||||
if state.first:
|
||||
state.aabb = global_aabb
|
||||
state.first = false
|
||||
else:
|
||||
state.aabb = state.aabb.merge(global_aabb)
|
||||
state.count += 1
|
||||
|
||||
for child in node.get_children():
|
||||
_collect_bounds(child, state)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfe6u1whveo5g
|
||||
133
ruf-der-pilze/addons/godot_mcp/commands/scene_commands.gd
Normal file
133
ruf-der-pilze/addons/godot_mcp/commands/scene_commands.gd
Normal file
@@ -0,0 +1,133 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPSceneCommands
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_current_scene": get_current_scene,
|
||||
"get_scene_tree": get_scene_tree,
|
||||
"open_scene": open_scene,
|
||||
"save_scene": save_scene,
|
||||
"create_scene": create_scene
|
||||
}
|
||||
|
||||
|
||||
func get_current_scene(_params: Dictionary) -> Dictionary:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if not root:
|
||||
return _success({
|
||||
"path": null,
|
||||
"root_name": null,
|
||||
"root_type": null
|
||||
})
|
||||
|
||||
return _success({
|
||||
"path": root.scene_file_path,
|
||||
"root_name": root.name,
|
||||
"root_type": root.get_class()
|
||||
})
|
||||
|
||||
|
||||
func get_scene_tree(_params: Dictionary) -> Dictionary:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if not root:
|
||||
return _error("NO_SCENE", "No scene is currently open")
|
||||
|
||||
return _success({"tree": _build_tree(root)})
|
||||
|
||||
|
||||
func _build_tree(node: Node) -> Dictionary:
|
||||
var result := {
|
||||
"name": node.name,
|
||||
"type": node.get_class(),
|
||||
}
|
||||
|
||||
if node is Node2D:
|
||||
var pos: Vector2 = node.position
|
||||
result["position"] = {"x": pos.x, "y": pos.y}
|
||||
elif node is Node3D:
|
||||
var pos: Vector3 = node.position
|
||||
result["position"] = {"x": pos.x, "y": pos.y, "z": pos.z}
|
||||
|
||||
var children: Array[Dictionary] = []
|
||||
for child in node.get_children():
|
||||
children.append(_build_tree(child))
|
||||
|
||||
if not children.is_empty():
|
||||
result["children"] = children
|
||||
|
||||
return result
|
||||
|
||||
|
||||
func open_scene(params: Dictionary) -> Dictionary:
|
||||
var scene_path: String = params.get("scene_path", "")
|
||||
if scene_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "scene_path is required")
|
||||
|
||||
if not FileAccess.file_exists(scene_path):
|
||||
return _error("FILE_NOT_FOUND", "Scene file not found: %s" % scene_path)
|
||||
|
||||
EditorInterface.open_scene_from_path(scene_path)
|
||||
return _success({"path": scene_path})
|
||||
|
||||
|
||||
func save_scene(params: Dictionary) -> Dictionary:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if not root:
|
||||
return _error("NO_SCENE", "No scene is currently open")
|
||||
|
||||
var path: String = params.get("path", "")
|
||||
if path.is_empty():
|
||||
path = root.scene_file_path
|
||||
|
||||
if path.is_empty():
|
||||
return _error("NO_PATH", "Scene has no path and none was provided")
|
||||
|
||||
var packed_scene := PackedScene.new()
|
||||
var err := packed_scene.pack(root)
|
||||
if err != OK:
|
||||
return _error("PACK_FAILED", "Failed to pack scene: %s" % error_string(err))
|
||||
|
||||
err = ResourceSaver.save(packed_scene, path)
|
||||
if err != OK:
|
||||
return _error("SAVE_FAILED", "Failed to save scene: %s" % error_string(err))
|
||||
|
||||
return _success({"path": path})
|
||||
|
||||
|
||||
func create_scene(params: Dictionary) -> Dictionary:
|
||||
var root_type: String = params.get("root_type", "")
|
||||
var root_name: String = params.get("root_name", root_type)
|
||||
var scene_path: String = params.get("scene_path", "")
|
||||
|
||||
if root_type.is_empty():
|
||||
return _error("INVALID_PARAMS", "root_type is required")
|
||||
if scene_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "scene_path is required")
|
||||
|
||||
if not ClassDB.class_exists(root_type):
|
||||
return _error("INVALID_TYPE", "Unknown node type: %s" % root_type)
|
||||
|
||||
var root: Node = ClassDB.instantiate(root_type)
|
||||
if not root:
|
||||
return _error("CREATE_FAILED", "Failed to create node of type: %s" % root_type)
|
||||
|
||||
root.name = root_name
|
||||
|
||||
var packed_scene := PackedScene.new()
|
||||
var err := packed_scene.pack(root)
|
||||
root.free()
|
||||
|
||||
if err != OK:
|
||||
return _error("PACK_FAILED", "Failed to pack scene: %s" % error_string(err))
|
||||
|
||||
err = ResourceSaver.save(packed_scene, scene_path)
|
||||
if err != OK:
|
||||
return _error("SAVE_FAILED", "Failed to save scene: %s" % error_string(err))
|
||||
|
||||
EditorInterface.open_scene_from_path(scene_path)
|
||||
|
||||
var uid := ResourceUID.id_to_text(ResourceLoader.get_resource_uid(scene_path))
|
||||
return _success({"path": scene_path, "uid": uid})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bv3do28j0hvxy
|
||||
130
ruf-der-pilze/addons/godot_mcp/commands/screenshot_commands.gd
Normal file
130
ruf-der-pilze/addons/godot_mcp/commands/screenshot_commands.gd
Normal file
@@ -0,0 +1,130 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPScreenshotCommands
|
||||
|
||||
const DEFAULT_MAX_WIDTH := 1920
|
||||
const SCREENSHOT_TIMEOUT := 5.0
|
||||
|
||||
var _screenshot_result: Dictionary = {}
|
||||
var _screenshot_pending: bool = false
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"capture_game_screenshot": capture_game_screenshot,
|
||||
"capture_editor_screenshot": capture_editor_screenshot
|
||||
}
|
||||
|
||||
|
||||
func capture_game_screenshot(params: Dictionary) -> Dictionary:
|
||||
if not EditorInterface.is_playing_scene():
|
||||
return _error("NOT_RUNNING", "No game is currently running. Use run_project first.")
|
||||
|
||||
var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
|
||||
|
||||
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
|
||||
if debugger_plugin == null:
|
||||
return _error("NO_DEBUGGER", "Debugger plugin not available")
|
||||
|
||||
if not debugger_plugin.has_active_session():
|
||||
return _error("NO_SESSION", "No active debug session. Game may not have MCPGameBridge autoload.")
|
||||
|
||||
_screenshot_pending = true
|
||||
_screenshot_result = {}
|
||||
|
||||
debugger_plugin.screenshot_received.connect(_on_screenshot_received, CONNECT_ONE_SHOT)
|
||||
debugger_plugin.request_screenshot(max_width)
|
||||
|
||||
var start_time := Time.get_ticks_msec()
|
||||
while _screenshot_pending:
|
||||
await Engine.get_main_loop().process_frame
|
||||
if (Time.get_ticks_msec() - start_time) / 1000.0 > SCREENSHOT_TIMEOUT:
|
||||
_screenshot_pending = false
|
||||
if debugger_plugin.screenshot_received.is_connected(_on_screenshot_received):
|
||||
debugger_plugin.screenshot_received.disconnect(_on_screenshot_received)
|
||||
return _error("TIMEOUT", "Screenshot request timed out")
|
||||
|
||||
return _screenshot_result
|
||||
|
||||
|
||||
func _on_screenshot_received(success: bool, image_base64: String, width: int, height: int, error: String) -> void:
|
||||
_screenshot_pending = false
|
||||
if success:
|
||||
_screenshot_result = _success({
|
||||
"image_base64": image_base64,
|
||||
"width": width,
|
||||
"height": height
|
||||
})
|
||||
else:
|
||||
_screenshot_result = _error("CAPTURE_FAILED", error)
|
||||
|
||||
|
||||
func capture_editor_screenshot(params: Dictionary) -> Dictionary:
|
||||
var viewport_type: String = params.get("viewport", "")
|
||||
var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
|
||||
|
||||
var viewport: SubViewport = null
|
||||
|
||||
if viewport_type == "2d":
|
||||
viewport = _find_2d_viewport()
|
||||
elif viewport_type == "3d":
|
||||
viewport = _find_3d_viewport()
|
||||
else:
|
||||
viewport = _find_active_viewport()
|
||||
|
||||
if viewport == null:
|
||||
return _error("NO_VIEWPORT", "Could not find editor viewport")
|
||||
|
||||
var image := viewport.get_texture().get_image()
|
||||
return _process_and_encode_image(image, max_width)
|
||||
|
||||
|
||||
func _process_and_encode_image(image: Image, max_width: int) -> Dictionary:
|
||||
if image == null:
|
||||
return _error("CAPTURE_FAILED", "Failed to capture image from viewport")
|
||||
|
||||
if max_width > 0 and image.get_width() > max_width:
|
||||
var scale_factor := float(max_width) / float(image.get_width())
|
||||
var new_height := int(image.get_height() * scale_factor)
|
||||
image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
|
||||
|
||||
var png_buffer := image.save_png_to_buffer()
|
||||
var base64 := Marshalls.raw_to_base64(png_buffer)
|
||||
|
||||
return _success({
|
||||
"image_base64": base64,
|
||||
"width": image.get_width(),
|
||||
"height": image.get_height()
|
||||
})
|
||||
|
||||
|
||||
func _find_active_viewport() -> SubViewport:
|
||||
var viewport := _find_3d_viewport()
|
||||
if viewport:
|
||||
return viewport
|
||||
return _find_2d_viewport()
|
||||
|
||||
|
||||
func _find_2d_viewport() -> SubViewport:
|
||||
var editor_main := EditorInterface.get_editor_main_screen()
|
||||
return _find_viewport_in_tree(editor_main, "2D")
|
||||
|
||||
|
||||
func _find_3d_viewport() -> SubViewport:
|
||||
var editor_main := EditorInterface.get_editor_main_screen()
|
||||
return _find_viewport_in_tree(editor_main, "3D")
|
||||
|
||||
|
||||
func _find_viewport_in_tree(node: Node, hint: String) -> SubViewport:
|
||||
if node is SubViewportContainer:
|
||||
var container := node as SubViewportContainer
|
||||
for child in container.get_children():
|
||||
if child is SubViewport:
|
||||
return child as SubViewport
|
||||
|
||||
for child in node.get_children():
|
||||
var result := _find_viewport_in_tree(child, hint)
|
||||
if result:
|
||||
return result
|
||||
|
||||
return null
|
||||
@@ -0,0 +1 @@
|
||||
uid://dhvxaokhfr0w5
|
||||
73
ruf-der-pilze/addons/godot_mcp/commands/script_commands.gd
Normal file
73
ruf-der-pilze/addons/godot_mcp/commands/script_commands.gd
Normal file
@@ -0,0 +1,73 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPScriptCommands
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_current_script": get_current_script,
|
||||
"attach_script": attach_script,
|
||||
"detach_script": detach_script
|
||||
}
|
||||
|
||||
|
||||
func get_current_script(_params: Dictionary) -> Dictionary:
|
||||
var script_editor := EditorInterface.get_script_editor()
|
||||
if not script_editor:
|
||||
return _success({"path": null, "content": null})
|
||||
|
||||
var current_script := script_editor.get_current_script()
|
||||
if not current_script:
|
||||
return _success({"path": null, "content": null})
|
||||
|
||||
return _success({
|
||||
"path": current_script.resource_path,
|
||||
"content": current_script.source_code
|
||||
})
|
||||
|
||||
|
||||
func attach_script(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
var script_path: String = params.get("script_path", "")
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if script_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "script_path is required")
|
||||
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
|
||||
if not FileAccess.file_exists(script_path):
|
||||
return _error("FILE_NOT_FOUND", "Script file not found: %s" % script_path)
|
||||
|
||||
var script := load(script_path) as Script
|
||||
if not script:
|
||||
return _error("LOAD_FAILED", "Failed to load script: %s" % script_path)
|
||||
|
||||
node.set_script(script)
|
||||
|
||||
EditorInterface.get_resource_filesystem().scan()
|
||||
script.reload()
|
||||
|
||||
if node.get_script() != script:
|
||||
return _error("ATTACH_FAILED", "Script attachment did not persist")
|
||||
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
return _success({"node_path": str(scene_root.get_path_to(node)), "script_path": script_path})
|
||||
|
||||
|
||||
func detach_script(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
|
||||
node.set_script(null)
|
||||
|
||||
return _success({})
|
||||
@@ -0,0 +1 @@
|
||||
uid://bka3ycfpg2ug
|
||||
170
ruf-der-pilze/addons/godot_mcp/commands/selection_commands.gd
Normal file
170
ruf-der-pilze/addons/godot_mcp/commands/selection_commands.gd
Normal file
@@ -0,0 +1,170 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPSelectionCommands
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"get_editor_state": get_editor_state,
|
||||
"get_selected_nodes": get_selected_nodes,
|
||||
"select_node": select_node,
|
||||
"set_2d_viewport": set_2d_viewport
|
||||
}
|
||||
|
||||
|
||||
func get_editor_state(_params: Dictionary) -> Dictionary:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
var open_scenes := EditorInterface.get_open_scenes()
|
||||
|
||||
var main_screen := _get_current_main_screen()
|
||||
|
||||
var result := {
|
||||
"current_scene": root.scene_file_path if root else null,
|
||||
"is_playing": EditorInterface.is_playing_scene(),
|
||||
"godot_version": Engine.get_version_info()["string"],
|
||||
"open_scenes": Array(open_scenes),
|
||||
"main_screen": main_screen
|
||||
}
|
||||
|
||||
if main_screen == "3D":
|
||||
var camera_info := _get_editor_camera_info()
|
||||
if not camera_info.is_empty():
|
||||
result["camera"] = camera_info
|
||||
elif main_screen == "2D":
|
||||
var viewport_2d_info := _get_editor_2d_viewport_info()
|
||||
if not viewport_2d_info.is_empty():
|
||||
result["viewport_2d"] = viewport_2d_info
|
||||
|
||||
return _success(result)
|
||||
|
||||
|
||||
func _get_editor_camera_info() -> Dictionary:
|
||||
var viewport := EditorInterface.get_editor_viewport_3d(0)
|
||||
if not viewport:
|
||||
return {}
|
||||
|
||||
var camera := viewport.get_camera_3d()
|
||||
if not camera:
|
||||
return {}
|
||||
|
||||
var pos: Vector3 = camera.global_position
|
||||
var rot: Vector3 = camera.global_rotation
|
||||
var forward: Vector3 = -camera.global_transform.basis.z
|
||||
|
||||
var info := {
|
||||
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
|
||||
"rotation": {"x": rot.x, "y": rot.y, "z": rot.z},
|
||||
"forward": {"x": forward.x, "y": forward.y, "z": forward.z},
|
||||
"fov": camera.fov,
|
||||
"near": camera.near,
|
||||
"far": camera.far,
|
||||
"projection": "orthogonal" if camera.projection == Camera3D.PROJECTION_ORTHOGONAL else "perspective",
|
||||
}
|
||||
|
||||
if camera.projection == Camera3D.PROJECTION_ORTHOGONAL:
|
||||
info["size"] = camera.size
|
||||
|
||||
return info
|
||||
|
||||
|
||||
func _get_editor_2d_viewport_info() -> Dictionary:
|
||||
var viewport := EditorInterface.get_editor_viewport_2d()
|
||||
if not viewport:
|
||||
return {}
|
||||
|
||||
var transform := viewport.global_canvas_transform
|
||||
var zoom: float = transform.x.x
|
||||
var offset: Vector2 = -transform.origin / zoom
|
||||
|
||||
var size := viewport.size
|
||||
|
||||
return {
|
||||
"center": {"x": offset.x + size.x / zoom / 2, "y": offset.y + size.y / zoom / 2},
|
||||
"zoom": zoom,
|
||||
"size": {"width": int(size.x), "height": int(size.y)}
|
||||
}
|
||||
|
||||
|
||||
func set_2d_viewport(params: Dictionary) -> Dictionary:
|
||||
var viewport := EditorInterface.get_editor_viewport_2d()
|
||||
if not viewport:
|
||||
return _error("NO_VIEWPORT", "Could not access 2D editor viewport")
|
||||
|
||||
var center_x: float = params.get("center_x", 0.0)
|
||||
var center_y: float = params.get("center_y", 0.0)
|
||||
var zoom: float = params.get("zoom", 1.0)
|
||||
|
||||
if zoom <= 0:
|
||||
return _error("INVALID_PARAMS", "zoom must be positive")
|
||||
|
||||
var size := viewport.size
|
||||
var offset := Vector2(center_x - size.x / zoom / 2, center_y - size.y / zoom / 2)
|
||||
var origin := -offset * zoom
|
||||
|
||||
var transform := Transform2D(Vector2(zoom, 0), Vector2(0, zoom), origin)
|
||||
viewport.global_canvas_transform = transform
|
||||
|
||||
return _success({
|
||||
"center": {"x": center_x, "y": center_y},
|
||||
"zoom": zoom
|
||||
})
|
||||
|
||||
|
||||
const MAIN_SCREEN_PATTERNS := {
|
||||
"2D": ["CanvasItemEditor", "2D"],
|
||||
"3D": ["Node3DEditor", "3D"],
|
||||
"Script": ["ScriptEditor", "Script"],
|
||||
"AssetLib": ["AssetLib", "Asset"],
|
||||
}
|
||||
|
||||
|
||||
func _get_current_main_screen() -> String:
|
||||
var main_screen := EditorInterface.get_editor_main_screen()
|
||||
if not main_screen:
|
||||
return "unknown"
|
||||
|
||||
for child in main_screen.get_children():
|
||||
if child.visible and child is Control:
|
||||
var cls := child.get_class()
|
||||
var node_name := child.name
|
||||
|
||||
for screen_name in MAIN_SCREEN_PATTERNS:
|
||||
var patterns: Array = MAIN_SCREEN_PATTERNS[screen_name]
|
||||
if patterns[0] in cls or patterns[1] in node_name:
|
||||
return screen_name
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
func get_selected_nodes(_params: Dictionary) -> Dictionary:
|
||||
var selection := EditorInterface.get_selection()
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
var selected: Array[String] = []
|
||||
|
||||
for node in selection.get_selected_nodes():
|
||||
if root and root.is_ancestor_of(node):
|
||||
# Build clean path relative to scene root
|
||||
var relative_path := root.get_path_to(node)
|
||||
var usable_path := "/root/" + root.name
|
||||
if relative_path != NodePath("."):
|
||||
usable_path += "/" + str(relative_path)
|
||||
selected.append(usable_path)
|
||||
elif node == root:
|
||||
selected.append("/root/" + root.name)
|
||||
|
||||
return _success({"selected": selected})
|
||||
|
||||
|
||||
func select_node(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
|
||||
var selection := EditorInterface.get_selection()
|
||||
selection.clear()
|
||||
selection.add_node(node)
|
||||
return _success({})
|
||||
@@ -0,0 +1 @@
|
||||
uid://mv0eiufscf2n
|
||||
37
ruf-der-pilze/addons/godot_mcp/commands/system_commands.gd
Normal file
37
ruf-der-pilze/addons/godot_mcp/commands/system_commands.gd
Normal file
@@ -0,0 +1,37 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPSystemCommands
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"mcp_handshake": mcp_handshake,
|
||||
"heartbeat": heartbeat,
|
||||
}
|
||||
|
||||
|
||||
func mcp_handshake(params: Dictionary) -> Dictionary:
|
||||
var server_version: String = params.get("server_version", "unknown")
|
||||
|
||||
if _plugin and _plugin.has_method("on_server_version_received"):
|
||||
_plugin.on_server_version_received(server_version)
|
||||
|
||||
return _success({
|
||||
"addon_version": _get_addon_version(),
|
||||
"godot_version": Engine.get_version_info()["string"],
|
||||
"project_path": ProjectSettings.globalize_path("res://"),
|
||||
"project_name": ProjectSettings.get_setting("application/config/name", ""),
|
||||
"server_version_received": server_version
|
||||
})
|
||||
|
||||
|
||||
func heartbeat(_params: Dictionary) -> Dictionary:
|
||||
return _success({"status": "ok"})
|
||||
|
||||
|
||||
func _get_addon_version() -> String:
|
||||
var config := ConfigFile.new()
|
||||
var err := config.load("res://addons/godot_mcp/plugin.cfg")
|
||||
if err == OK:
|
||||
return config.get_value("plugin", "version", "unknown")
|
||||
return "unknown"
|
||||
@@ -0,0 +1 @@
|
||||
uid://c22fvs7y7mjiu
|
||||
665
ruf-der-pilze/addons/godot_mcp/commands/tilemap_commands.gd
Normal file
665
ruf-der-pilze/addons/godot_mcp/commands/tilemap_commands.gd
Normal file
@@ -0,0 +1,665 @@
|
||||
@tool
|
||||
extends MCPBaseCommand
|
||||
class_name MCPTilemapCommands
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {
|
||||
"list_tilemap_layers": list_tilemap_layers,
|
||||
"get_tilemap_layer_info": get_tilemap_layer_info,
|
||||
"get_tileset_info": get_tileset_info,
|
||||
"get_used_cells": get_used_cells,
|
||||
"get_cell": get_cell,
|
||||
"set_cell": set_cell,
|
||||
"erase_cell": erase_cell,
|
||||
"clear_layer": clear_layer,
|
||||
"get_cells_in_region": get_cells_in_region,
|
||||
"set_cells_batch": set_cells_batch,
|
||||
"convert_coords": convert_coords,
|
||||
"list_gridmaps": list_gridmaps,
|
||||
"get_gridmap_info": get_gridmap_info,
|
||||
"get_meshlib_info": get_meshlib_info,
|
||||
"get_gridmap_used_cells": get_gridmap_used_cells,
|
||||
"get_gridmap_cell": get_gridmap_cell,
|
||||
"set_gridmap_cell": set_gridmap_cell,
|
||||
"clear_gridmap_cell": clear_gridmap_cell,
|
||||
"clear_gridmap": clear_gridmap,
|
||||
"get_cells_by_item": get_cells_by_item,
|
||||
"set_gridmap_cells_batch": set_gridmap_cells_batch,
|
||||
}
|
||||
|
||||
|
||||
func _get_tilemap_layer(node_path: String) -> TileMapLayer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return null
|
||||
if not node is TileMapLayer:
|
||||
return null
|
||||
return node as TileMapLayer
|
||||
|
||||
|
||||
func _get_gridmap(node_path: String) -> GridMap:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return null
|
||||
if not node is GridMap:
|
||||
return null
|
||||
return node as GridMap
|
||||
|
||||
|
||||
func _find_tilemap_layers(node: Node, result: Array, scene_root: Node) -> void:
|
||||
if node is TileMapLayer:
|
||||
result.append({
|
||||
"path": str(scene_root.get_path_to(node)),
|
||||
"name": node.name
|
||||
})
|
||||
for child in node.get_children():
|
||||
_find_tilemap_layers(child, result, scene_root)
|
||||
|
||||
|
||||
func _find_gridmaps(node: Node, result: Array, scene_root: Node) -> void:
|
||||
if node is GridMap:
|
||||
result.append({
|
||||
"path": str(scene_root.get_path_to(node)),
|
||||
"name": node.name
|
||||
})
|
||||
for child in node.get_children():
|
||||
_find_gridmaps(child, result, scene_root)
|
||||
|
||||
|
||||
func _serialize_vector2i(v: Vector2i) -> Dictionary:
|
||||
return {"x": v.x, "y": v.y}
|
||||
|
||||
|
||||
func _serialize_vector3i(v: Vector3i) -> Dictionary:
|
||||
return {"x": v.x, "y": v.y, "z": v.z}
|
||||
|
||||
|
||||
func _deserialize_vector2i(d: Dictionary) -> Vector2i:
|
||||
return Vector2i(int(d.get("x", 0)), int(d.get("y", 0)))
|
||||
|
||||
|
||||
func _deserialize_vector3i(d: Dictionary) -> Vector3i:
|
||||
return Vector3i(int(d.get("x", 0)), int(d.get("y", 0)), int(d.get("z", 0)))
|
||||
|
||||
|
||||
func list_tilemap_layers(params: Dictionary) -> Dictionary:
|
||||
var root_path: String = params.get("root_path", "")
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var root: Node
|
||||
|
||||
if not scene_root:
|
||||
return _error("NO_SCENE", "No scene is open")
|
||||
|
||||
if root_path.is_empty():
|
||||
root = scene_root
|
||||
else:
|
||||
root = _get_node(root_path)
|
||||
|
||||
if not root:
|
||||
return _error("NODE_NOT_FOUND", "Root node not found")
|
||||
|
||||
var layers := []
|
||||
_find_tilemap_layers(root, layers, scene_root)
|
||||
|
||||
return _success({"tilemap_layers": layers})
|
||||
|
||||
|
||||
func get_tilemap_layer_info(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var layer := _get_tilemap_layer(node_path)
|
||||
if not layer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
|
||||
|
||||
var tileset_path := ""
|
||||
if layer.tile_set:
|
||||
tileset_path = layer.tile_set.resource_path
|
||||
|
||||
return _success({
|
||||
"name": layer.name,
|
||||
"enabled": layer.enabled,
|
||||
"tileset_path": tileset_path,
|
||||
"cell_quadrant_size": layer.rendering_quadrant_size,
|
||||
"collision_enabled": layer.collision_enabled,
|
||||
"used_cells_count": layer.get_used_cells().size()
|
||||
})
|
||||
|
||||
|
||||
func get_tileset_info(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var layer := _get_tilemap_layer(node_path)
|
||||
if not layer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
|
||||
|
||||
if not layer.tile_set:
|
||||
return _error("NO_TILESET", "TileMapLayer has no TileSet assigned")
|
||||
|
||||
var tileset := layer.tile_set
|
||||
var sources := []
|
||||
|
||||
for i in range(tileset.get_source_count()):
|
||||
var source_id := tileset.get_source_id(i)
|
||||
var source := tileset.get_source(source_id)
|
||||
var source_info := {
|
||||
"source_id": source_id,
|
||||
"source_type": "unknown"
|
||||
}
|
||||
|
||||
if source is TileSetAtlasSource:
|
||||
var atlas := source as TileSetAtlasSource
|
||||
source_info["source_type"] = "atlas"
|
||||
source_info["texture_path"] = atlas.texture.resource_path if atlas.texture else ""
|
||||
source_info["texture_region_size"] = _serialize_vector2i(atlas.texture_region_size)
|
||||
source_info["tile_count"] = atlas.get_tiles_count()
|
||||
elif source is TileSetScenesCollectionSource:
|
||||
source_info["source_type"] = "scenes_collection"
|
||||
var scenes_source := source as TileSetScenesCollectionSource
|
||||
source_info["scene_count"] = scenes_source.get_scene_tiles_count()
|
||||
|
||||
sources.append(source_info)
|
||||
|
||||
return _success({
|
||||
"tileset_path": tileset.resource_path,
|
||||
"tile_size": _serialize_vector2i(tileset.tile_size),
|
||||
"source_count": tileset.get_source_count(),
|
||||
"sources": sources
|
||||
})
|
||||
|
||||
|
||||
func get_used_cells(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var layer := _get_tilemap_layer(node_path)
|
||||
if not layer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
|
||||
|
||||
var cells := layer.get_used_cells()
|
||||
var result := []
|
||||
for cell in cells:
|
||||
result.append(_serialize_vector2i(cell))
|
||||
|
||||
return _success({"cells": result, "count": result.size()})
|
||||
|
||||
|
||||
func get_cell(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("coords"):
|
||||
return _error("INVALID_PARAMS", "coords is required")
|
||||
|
||||
var layer := _get_tilemap_layer(node_path)
|
||||
if not layer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
|
||||
|
||||
var coords := _deserialize_vector2i(params["coords"])
|
||||
var source_id := layer.get_cell_source_id(coords)
|
||||
var atlas_coords := layer.get_cell_atlas_coords(coords)
|
||||
var alt_tile := layer.get_cell_alternative_tile(coords)
|
||||
|
||||
if source_id == -1:
|
||||
return _success({
|
||||
"coords": _serialize_vector2i(coords),
|
||||
"empty": true
|
||||
})
|
||||
|
||||
return _success({
|
||||
"coords": _serialize_vector2i(coords),
|
||||
"empty": false,
|
||||
"source_id": source_id,
|
||||
"atlas_coords": _serialize_vector2i(atlas_coords),
|
||||
"alternative_tile": alt_tile
|
||||
})
|
||||
|
||||
|
||||
func set_cell(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("coords"):
|
||||
return _error("INVALID_PARAMS", "coords is required")
|
||||
|
||||
var layer := _get_tilemap_layer(node_path)
|
||||
if not layer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
|
||||
|
||||
var coords := _deserialize_vector2i(params["coords"])
|
||||
var source_id: int = params.get("source_id", 0)
|
||||
var atlas_coords := Vector2i(0, 0)
|
||||
if params.has("atlas_coords"):
|
||||
atlas_coords = _deserialize_vector2i(params["atlas_coords"])
|
||||
var alt_tile: int = params.get("alternative_tile", 0)
|
||||
|
||||
layer.set_cell(coords, source_id, atlas_coords, alt_tile)
|
||||
|
||||
return _success({
|
||||
"coords": _serialize_vector2i(coords),
|
||||
"source_id": source_id,
|
||||
"atlas_coords": _serialize_vector2i(atlas_coords),
|
||||
"alternative_tile": alt_tile
|
||||
})
|
||||
|
||||
|
||||
func erase_cell(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("coords"):
|
||||
return _error("INVALID_PARAMS", "coords is required")
|
||||
|
||||
var layer := _get_tilemap_layer(node_path)
|
||||
if not layer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
|
||||
|
||||
var coords := _deserialize_vector2i(params["coords"])
|
||||
layer.erase_cell(coords)
|
||||
|
||||
return _success({"erased": _serialize_vector2i(coords)})
|
||||
|
||||
|
||||
func clear_layer(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var layer := _get_tilemap_layer(node_path)
|
||||
if not layer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
|
||||
|
||||
var count := layer.get_used_cells().size()
|
||||
layer.clear()
|
||||
|
||||
return _success({"cleared": true, "cells_removed": count})
|
||||
|
||||
|
||||
func get_cells_in_region(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("min_coords"):
|
||||
return _error("INVALID_PARAMS", "min_coords is required")
|
||||
if not params.has("max_coords"):
|
||||
return _error("INVALID_PARAMS", "max_coords is required")
|
||||
|
||||
var layer := _get_tilemap_layer(node_path)
|
||||
if not layer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
|
||||
|
||||
var min_coords := _deserialize_vector2i(params["min_coords"])
|
||||
var max_coords := _deserialize_vector2i(params["max_coords"])
|
||||
|
||||
var cells := []
|
||||
for cell in layer.get_used_cells():
|
||||
if cell.x >= min_coords.x and cell.x <= max_coords.x and cell.y >= min_coords.y and cell.y <= max_coords.y:
|
||||
var source_id := layer.get_cell_source_id(cell)
|
||||
var atlas_coords := layer.get_cell_atlas_coords(cell)
|
||||
var alt_tile := layer.get_cell_alternative_tile(cell)
|
||||
cells.append({
|
||||
"coords": _serialize_vector2i(cell),
|
||||
"source_id": source_id,
|
||||
"atlas_coords": _serialize_vector2i(atlas_coords),
|
||||
"alternative_tile": alt_tile
|
||||
})
|
||||
|
||||
return _success({"cells": cells, "count": cells.size()})
|
||||
|
||||
|
||||
func set_cells_batch(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("cells"):
|
||||
return _error("INVALID_PARAMS", "cells is required")
|
||||
|
||||
var layer := _get_tilemap_layer(node_path)
|
||||
if not layer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
|
||||
|
||||
var cells_data: Array = params["cells"]
|
||||
var count := 0
|
||||
|
||||
for cell_data in cells_data:
|
||||
if not cell_data.has("coords"):
|
||||
continue
|
||||
var coords := _deserialize_vector2i(cell_data["coords"])
|
||||
var source_id: int = cell_data.get("source_id", 0)
|
||||
var atlas_coords := Vector2i(0, 0)
|
||||
if cell_data.has("atlas_coords"):
|
||||
atlas_coords = _deserialize_vector2i(cell_data["atlas_coords"])
|
||||
var alt_tile: int = cell_data.get("alternative_tile", 0)
|
||||
|
||||
layer.set_cell(coords, source_id, atlas_coords, alt_tile)
|
||||
count += 1
|
||||
|
||||
return _success({"cells_set": count})
|
||||
|
||||
|
||||
func convert_coords(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var layer := _get_tilemap_layer(node_path)
|
||||
if not layer:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
|
||||
|
||||
if params.has("local_position"):
|
||||
var local_pos_data: Dictionary = params["local_position"]
|
||||
var local_pos := Vector2(local_pos_data.get("x", 0.0), local_pos_data.get("y", 0.0))
|
||||
var map_coords := layer.local_to_map(local_pos)
|
||||
return _success({
|
||||
"direction": "local_to_map",
|
||||
"local_position": {"x": local_pos.x, "y": local_pos.y},
|
||||
"map_coords": _serialize_vector2i(map_coords)
|
||||
})
|
||||
elif params.has("map_coords"):
|
||||
var map_coords := _deserialize_vector2i(params["map_coords"])
|
||||
var local_pos := layer.map_to_local(map_coords)
|
||||
return _success({
|
||||
"direction": "map_to_local",
|
||||
"map_coords": _serialize_vector2i(map_coords),
|
||||
"local_position": {"x": local_pos.x, "y": local_pos.y}
|
||||
})
|
||||
else:
|
||||
return _error("INVALID_PARAMS", "Either local_position or map_coords is required")
|
||||
|
||||
|
||||
func list_gridmaps(params: Dictionary) -> Dictionary:
|
||||
var root_path: String = params.get("root_path", "")
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
var root: Node
|
||||
|
||||
if not scene_root:
|
||||
return _error("NO_SCENE", "No scene is open")
|
||||
|
||||
if root_path.is_empty():
|
||||
root = scene_root
|
||||
else:
|
||||
root = _get_node(root_path)
|
||||
|
||||
if not root:
|
||||
return _error("NODE_NOT_FOUND", "Root node not found")
|
||||
|
||||
var gridmaps := []
|
||||
_find_gridmaps(root, gridmaps, scene_root)
|
||||
|
||||
return _success({"gridmaps": gridmaps})
|
||||
|
||||
|
||||
func get_gridmap_info(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var gridmap := _get_gridmap(node_path)
|
||||
if not gridmap:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
|
||||
|
||||
var meshlib_path := ""
|
||||
if gridmap.mesh_library:
|
||||
meshlib_path = gridmap.mesh_library.resource_path
|
||||
|
||||
return _success({
|
||||
"name": gridmap.name,
|
||||
"mesh_library_path": meshlib_path,
|
||||
"cell_size": _serialize_value(gridmap.cell_size),
|
||||
"cell_center_x": gridmap.cell_center_x,
|
||||
"cell_center_y": gridmap.cell_center_y,
|
||||
"cell_center_z": gridmap.cell_center_z,
|
||||
"used_cells_count": gridmap.get_used_cells().size()
|
||||
})
|
||||
|
||||
|
||||
func get_meshlib_info(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var gridmap := _get_gridmap(node_path)
|
||||
if not gridmap:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
|
||||
|
||||
if not gridmap.mesh_library:
|
||||
return _error("NO_MESH_LIBRARY", "GridMap has no MeshLibrary assigned")
|
||||
|
||||
var meshlib := gridmap.mesh_library
|
||||
var items := []
|
||||
|
||||
for i in range(meshlib.get_item_list().size()):
|
||||
var item_id: int = meshlib.get_item_list()[i]
|
||||
var item_name := meshlib.get_item_name(item_id)
|
||||
var mesh := meshlib.get_item_mesh(item_id)
|
||||
var mesh_path := mesh.resource_path if mesh else ""
|
||||
|
||||
items.append({
|
||||
"index": item_id,
|
||||
"name": item_name,
|
||||
"mesh_path": mesh_path
|
||||
})
|
||||
|
||||
return _success({
|
||||
"mesh_library_path": meshlib.resource_path,
|
||||
"item_count": meshlib.get_item_list().size(),
|
||||
"items": items
|
||||
})
|
||||
|
||||
|
||||
func get_gridmap_used_cells(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var gridmap := _get_gridmap(node_path)
|
||||
if not gridmap:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
|
||||
|
||||
var cells := gridmap.get_used_cells()
|
||||
var result := []
|
||||
for cell in cells:
|
||||
result.append(_serialize_vector3i(cell))
|
||||
|
||||
return _success({"cells": result, "count": result.size()})
|
||||
|
||||
|
||||
func get_gridmap_cell(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("coords"):
|
||||
return _error("INVALID_PARAMS", "coords is required")
|
||||
|
||||
var gridmap := _get_gridmap(node_path)
|
||||
if not gridmap:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
|
||||
|
||||
var coords := _deserialize_vector3i(params["coords"])
|
||||
var item := gridmap.get_cell_item(coords)
|
||||
var orientation := gridmap.get_cell_item_orientation(coords)
|
||||
|
||||
if item == GridMap.INVALID_CELL_ITEM:
|
||||
return _success({
|
||||
"coords": _serialize_vector3i(coords),
|
||||
"empty": true
|
||||
})
|
||||
|
||||
var item_name := ""
|
||||
if gridmap.mesh_library:
|
||||
item_name = gridmap.mesh_library.get_item_name(item)
|
||||
|
||||
return _success({
|
||||
"coords": _serialize_vector3i(coords),
|
||||
"empty": false,
|
||||
"item": item,
|
||||
"item_name": item_name,
|
||||
"orientation": orientation
|
||||
})
|
||||
|
||||
|
||||
func set_gridmap_cell(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("coords"):
|
||||
return _error("INVALID_PARAMS", "coords is required")
|
||||
if not params.has("item"):
|
||||
return _error("INVALID_PARAMS", "item is required")
|
||||
|
||||
var gridmap := _get_gridmap(node_path)
|
||||
if not gridmap:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
|
||||
|
||||
var coords := _deserialize_vector3i(params["coords"])
|
||||
var item: int = params["item"]
|
||||
var orientation: int = params.get("orientation", 0)
|
||||
|
||||
gridmap.set_cell_item(coords, item, orientation)
|
||||
|
||||
return _success({
|
||||
"coords": _serialize_vector3i(coords),
|
||||
"item": item,
|
||||
"orientation": orientation
|
||||
})
|
||||
|
||||
|
||||
func clear_gridmap_cell(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("coords"):
|
||||
return _error("INVALID_PARAMS", "coords is required")
|
||||
|
||||
var gridmap := _get_gridmap(node_path)
|
||||
if not gridmap:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
|
||||
|
||||
var coords := _deserialize_vector3i(params["coords"])
|
||||
gridmap.set_cell_item(coords, GridMap.INVALID_CELL_ITEM)
|
||||
|
||||
return _success({"cleared": _serialize_vector3i(coords)})
|
||||
|
||||
|
||||
func clear_gridmap(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
|
||||
var gridmap := _get_gridmap(node_path)
|
||||
if not gridmap:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
|
||||
|
||||
var count := gridmap.get_used_cells().size()
|
||||
gridmap.clear()
|
||||
|
||||
return _success({"cleared": true, "cells_removed": count})
|
||||
|
||||
|
||||
func get_cells_by_item(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("item"):
|
||||
return _error("INVALID_PARAMS", "item is required")
|
||||
|
||||
var gridmap := _get_gridmap(node_path)
|
||||
if not gridmap:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
|
||||
|
||||
var item: int = params["item"]
|
||||
var cells := gridmap.get_used_cells_by_item(item)
|
||||
var result := []
|
||||
for cell in cells:
|
||||
result.append(_serialize_vector3i(cell))
|
||||
|
||||
return _success({"item": item, "cells": result, "count": result.size()})
|
||||
|
||||
|
||||
func set_gridmap_cells_batch(params: Dictionary) -> Dictionary:
|
||||
var node_path: String = params.get("node_path", "")
|
||||
if node_path.is_empty():
|
||||
return _error("INVALID_PARAMS", "node_path is required")
|
||||
if not params.has("cells"):
|
||||
return _error("INVALID_PARAMS", "cells is required")
|
||||
|
||||
var gridmap := _get_gridmap(node_path)
|
||||
if not gridmap:
|
||||
var node := _get_node(node_path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
|
||||
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
|
||||
|
||||
var cells_data: Array = params["cells"]
|
||||
var count := 0
|
||||
|
||||
for cell_data in cells_data:
|
||||
if not cell_data.has("coords") or not cell_data.has("item"):
|
||||
continue
|
||||
var coords := _deserialize_vector3i(cell_data["coords"])
|
||||
var item: int = cell_data["item"]
|
||||
var orientation: int = cell_data.get("orientation", 0)
|
||||
|
||||
gridmap.set_cell_item(coords, item, orientation)
|
||||
count += 1
|
||||
|
||||
return _success({"cells_set": count})
|
||||
@@ -0,0 +1 @@
|
||||
uid://cuducprbjgili
|
||||
60
ruf-der-pilze/addons/godot_mcp/core/base_command.gd
Normal file
60
ruf-der-pilze/addons/godot_mcp/core/base_command.gd
Normal file
@@ -0,0 +1,60 @@
|
||||
@tool
|
||||
class_name MCPBaseCommand
|
||||
extends RefCounted
|
||||
|
||||
var _plugin: EditorPlugin
|
||||
|
||||
|
||||
func setup(plugin: EditorPlugin) -> void:
|
||||
_plugin = plugin
|
||||
|
||||
|
||||
func get_commands() -> Dictionary:
|
||||
return {}
|
||||
|
||||
|
||||
func _success(result: Dictionary) -> Dictionary:
|
||||
return MCPUtils.success(result)
|
||||
|
||||
|
||||
func _error(code: String, message: String) -> Dictionary:
|
||||
return MCPUtils.error(code, message)
|
||||
|
||||
|
||||
func _get_node(path: String) -> Node:
|
||||
return MCPUtils.get_node_from_path(path)
|
||||
|
||||
|
||||
func _serialize_value(value: Variant) -> Variant:
|
||||
return MCPUtils.serialize_value(value)
|
||||
|
||||
|
||||
func _require_scene_open() -> Dictionary:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if not root:
|
||||
return _error("NO_SCENE", "No scene is currently open")
|
||||
return {}
|
||||
|
||||
|
||||
func _require_typed_node(path: String, type: String, type_error_code: String = "WRONG_TYPE") -> Variant:
|
||||
var node := _get_node(path)
|
||||
if not node:
|
||||
return _error("NODE_NOT_FOUND", "Node not found: %s" % path)
|
||||
if not node.is_class(type):
|
||||
return _error(type_error_code, "Expected %s, got %s" % [type, node.get_class()])
|
||||
return node
|
||||
|
||||
|
||||
func _find_nodes_of_type(root: Node, type: String) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var scene_root := EditorInterface.get_edited_scene_root()
|
||||
if scene_root:
|
||||
_find_nodes_recursive(root, type, result, scene_root)
|
||||
return result
|
||||
|
||||
|
||||
func _find_nodes_recursive(node: Node, type: String, result: Array[Dictionary], scene_root: Node) -> void:
|
||||
if node.is_class(type):
|
||||
result.append({"path": str(scene_root.get_path_to(node)), "name": node.name})
|
||||
for child in node.get_children():
|
||||
_find_nodes_recursive(child, type, result, scene_root)
|
||||
1
ruf-der-pilze/addons/godot_mcp/core/base_command.gd.uid
Normal file
1
ruf-der-pilze/addons/godot_mcp/core/base_command.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://btrhwuu4jmt7
|
||||
5
ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd
Normal file
5
ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd
Normal file
@@ -0,0 +1,5 @@
|
||||
class_name MCPConstants
|
||||
|
||||
const LOCALHOST_BIND_ADDRESS = "127.0.0.1"
|
||||
const PORT_MIN = 1
|
||||
const PORT_MAX = 65535
|
||||
1
ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd.uid
Normal file
1
ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://daod4t3pjl3ce
|
||||
283
ruf-der-pilze/addons/godot_mcp/core/mcp_debugger_plugin.gd
Normal file
283
ruf-der-pilze/addons/godot_mcp/core/mcp_debugger_plugin.gd
Normal file
@@ -0,0 +1,283 @@
|
||||
@tool
|
||||
extends EditorDebuggerPlugin
|
||||
class_name MCPDebuggerPlugin
|
||||
|
||||
signal screenshot_received(success: bool, image_base64: String, width: int, height: int, error: String)
|
||||
signal debug_output_received(output: PackedStringArray)
|
||||
signal performance_metrics_received(metrics: Dictionary)
|
||||
signal find_nodes_received(matches: Array, count: int, error: String)
|
||||
signal input_map_received(actions: Array, error: String)
|
||||
signal input_sequence_completed(result: Dictionary)
|
||||
signal type_text_completed(result: Dictionary)
|
||||
signal game_response(message_type: String, data: Variant)
|
||||
|
||||
var _active_session_id: int = -1
|
||||
var _pending_screenshot: bool = false
|
||||
var _pending_debug_output: bool = false
|
||||
var _pending_performance_metrics: bool = false
|
||||
var _pending_find_nodes: bool = false
|
||||
var _pending_input_map: bool = false
|
||||
var _pending_input_sequence: bool = false
|
||||
var _pending_type_text: bool = false
|
||||
var _pending_requests: Dictionary = {}
|
||||
var _responses: Dictionary = {}
|
||||
|
||||
|
||||
func _has_capture(prefix: String) -> bool:
|
||||
return prefix == "godot_mcp"
|
||||
|
||||
|
||||
func _capture(message: String, data: Array, session_id: int) -> bool:
|
||||
match message:
|
||||
"godot_mcp:screenshot_result":
|
||||
_handle_screenshot_result(data)
|
||||
return true
|
||||
"godot_mcp:debug_output_result":
|
||||
_handle_debug_output_result(data)
|
||||
return true
|
||||
"godot_mcp:performance_metrics_result":
|
||||
_handle_performance_metrics_result(data)
|
||||
return true
|
||||
"godot_mcp:find_nodes_result":
|
||||
_handle_find_nodes_result(data)
|
||||
return true
|
||||
"godot_mcp:input_map_result":
|
||||
_handle_input_map_result(data)
|
||||
return true
|
||||
"godot_mcp:input_sequence_result":
|
||||
_handle_input_sequence_result(data)
|
||||
return true
|
||||
"godot_mcp:type_text_result":
|
||||
_handle_type_text_result(data)
|
||||
return true
|
||||
"godot_mcp:game_response":
|
||||
_handle_game_response(data)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _setup_session(session_id: int) -> void:
|
||||
_active_session_id = session_id
|
||||
|
||||
|
||||
func _session_stopped() -> void:
|
||||
_active_session_id = -1
|
||||
if _pending_screenshot:
|
||||
_pending_screenshot = false
|
||||
screenshot_received.emit(false, "", 0, 0, "Game session ended")
|
||||
if _pending_debug_output:
|
||||
_pending_debug_output = false
|
||||
debug_output_received.emit(PackedStringArray())
|
||||
if _pending_performance_metrics:
|
||||
_pending_performance_metrics = false
|
||||
performance_metrics_received.emit({})
|
||||
if _pending_find_nodes:
|
||||
_pending_find_nodes = false
|
||||
find_nodes_received.emit([], 0, "Game session ended")
|
||||
if _pending_input_map:
|
||||
_pending_input_map = false
|
||||
input_map_received.emit([], "Game session ended")
|
||||
if _pending_input_sequence:
|
||||
_pending_input_sequence = false
|
||||
input_sequence_completed.emit({"error": "Game session ended"})
|
||||
if _pending_type_text:
|
||||
_pending_type_text = false
|
||||
type_text_completed.emit({"error": "Game session ended"})
|
||||
for msg_type in _pending_requests:
|
||||
_responses[msg_type] = {}
|
||||
_pending_requests.clear()
|
||||
|
||||
|
||||
func has_active_session() -> bool:
|
||||
if _active_session_id < 0:
|
||||
return false
|
||||
if not EditorInterface.is_playing_scene():
|
||||
_active_session_id = -1
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func request_screenshot(max_width: int = 1920) -> void:
|
||||
if _active_session_id < 0:
|
||||
screenshot_received.emit(false, "", 0, 0, "No active game session")
|
||||
return
|
||||
_pending_screenshot = true
|
||||
var session := get_session(_active_session_id)
|
||||
if session:
|
||||
session.send_message("godot_mcp:take_screenshot", [max_width])
|
||||
else:
|
||||
_pending_screenshot = false
|
||||
screenshot_received.emit(false, "", 0, 0, "Could not get debugger session")
|
||||
|
||||
|
||||
func _handle_screenshot_result(data: Array) -> void:
|
||||
_pending_screenshot = false
|
||||
if data.size() < 5:
|
||||
screenshot_received.emit(false, "", 0, 0, "Invalid response data")
|
||||
return
|
||||
var success: bool = data[0]
|
||||
var image_base64: String = data[1]
|
||||
var width: int = data[2]
|
||||
var height: int = data[3]
|
||||
var error: String = data[4]
|
||||
screenshot_received.emit(success, image_base64, width, height, error)
|
||||
|
||||
|
||||
func request_debug_output(clear: bool = false) -> void:
|
||||
if _active_session_id < 0:
|
||||
debug_output_received.emit(PackedStringArray())
|
||||
return
|
||||
_pending_debug_output = true
|
||||
var session := get_session(_active_session_id)
|
||||
if session:
|
||||
session.send_message("godot_mcp:get_debug_output", [clear])
|
||||
else:
|
||||
_pending_debug_output = false
|
||||
debug_output_received.emit(PackedStringArray())
|
||||
|
||||
|
||||
func _handle_debug_output_result(data: Array) -> void:
|
||||
_pending_debug_output = false
|
||||
var output: PackedStringArray = data[0] if data.size() > 0 else PackedStringArray()
|
||||
debug_output_received.emit(output)
|
||||
|
||||
|
||||
func request_performance_metrics() -> void:
|
||||
if _active_session_id < 0:
|
||||
performance_metrics_received.emit({})
|
||||
return
|
||||
_pending_performance_metrics = true
|
||||
var session := get_session(_active_session_id)
|
||||
if session:
|
||||
session.send_message("godot_mcp:get_performance_metrics", [])
|
||||
else:
|
||||
_pending_performance_metrics = false
|
||||
performance_metrics_received.emit({})
|
||||
|
||||
|
||||
func _handle_performance_metrics_result(data: Array) -> void:
|
||||
_pending_performance_metrics = false
|
||||
var metrics: Dictionary = data[0] if data.size() > 0 else {}
|
||||
performance_metrics_received.emit(metrics)
|
||||
|
||||
|
||||
func request_find_nodes(name_pattern: String, type_filter: String, root_path: String) -> void:
|
||||
if _active_session_id < 0:
|
||||
find_nodes_received.emit([], 0, "No active game session")
|
||||
return
|
||||
_pending_find_nodes = true
|
||||
var session := get_session(_active_session_id)
|
||||
if session:
|
||||
session.send_message("godot_mcp:find_nodes", [name_pattern, type_filter, root_path])
|
||||
else:
|
||||
_pending_find_nodes = false
|
||||
find_nodes_received.emit([], 0, "Could not get debugger session")
|
||||
|
||||
|
||||
func _handle_find_nodes_result(data: Array) -> void:
|
||||
_pending_find_nodes = false
|
||||
var matches: Array = data[0] if data.size() > 0 else []
|
||||
var count: int = data[1] if data.size() > 1 else 0
|
||||
var error: String = data[2] if data.size() > 2 else ""
|
||||
find_nodes_received.emit(matches, count, error)
|
||||
|
||||
|
||||
func request_input_map() -> void:
|
||||
if _active_session_id < 0:
|
||||
input_map_received.emit([], "No active game session")
|
||||
return
|
||||
_pending_input_map = true
|
||||
var session := get_session(_active_session_id)
|
||||
if session:
|
||||
session.send_message("godot_mcp:get_input_map", [])
|
||||
else:
|
||||
_pending_input_map = false
|
||||
input_map_received.emit([], "Could not get debugger session")
|
||||
|
||||
|
||||
func _handle_input_map_result(data: Array) -> void:
|
||||
_pending_input_map = false
|
||||
var actions: Array = data[0] if data.size() > 0 else []
|
||||
var error: String = data[1] if data.size() > 1 else ""
|
||||
input_map_received.emit(actions, error)
|
||||
|
||||
|
||||
func request_input_sequence(inputs: Array) -> void:
|
||||
if _active_session_id < 0:
|
||||
input_sequence_completed.emit({"error": "No active game session"})
|
||||
return
|
||||
_pending_input_sequence = true
|
||||
var session := get_session(_active_session_id)
|
||||
if session:
|
||||
session.send_message("godot_mcp:execute_input_sequence", [inputs])
|
||||
else:
|
||||
_pending_input_sequence = false
|
||||
input_sequence_completed.emit({"error": "Could not get debugger session"})
|
||||
|
||||
|
||||
func _handle_input_sequence_result(data: Array) -> void:
|
||||
_pending_input_sequence = false
|
||||
var result: Dictionary = data[0] if data.size() > 0 else {}
|
||||
input_sequence_completed.emit(result)
|
||||
|
||||
|
||||
func request_type_text(text: String, delay_ms: int, submit: bool) -> void:
|
||||
if _active_session_id < 0:
|
||||
type_text_completed.emit({"error": "No active game session"})
|
||||
return
|
||||
_pending_type_text = true
|
||||
var session := get_session(_active_session_id)
|
||||
if session:
|
||||
session.send_message("godot_mcp:type_text", [text, delay_ms, submit])
|
||||
else:
|
||||
_pending_type_text = false
|
||||
type_text_completed.emit({"error": "Could not get debugger session"})
|
||||
|
||||
|
||||
func _handle_type_text_result(data: Array) -> void:
|
||||
_pending_type_text = false
|
||||
var result: Dictionary = data[0] if data.size() > 0 else {}
|
||||
type_text_completed.emit(result)
|
||||
|
||||
|
||||
func send_game_message(msg_type: String, args: Array = []) -> bool:
|
||||
if _active_session_id < 0:
|
||||
return false
|
||||
var session := get_session(_active_session_id)
|
||||
if not session:
|
||||
return false
|
||||
_pending_requests[msg_type] = true
|
||||
_responses.erase(msg_type)
|
||||
session.send_message("godot_mcp:" + msg_type, args)
|
||||
return true
|
||||
|
||||
|
||||
func has_response(msg_type: String) -> bool:
|
||||
return _responses.has(msg_type)
|
||||
|
||||
|
||||
func get_response(msg_type: String) -> Variant:
|
||||
return _responses.get(msg_type)
|
||||
|
||||
|
||||
func clear_response(msg_type: String) -> void:
|
||||
_responses.erase(msg_type)
|
||||
_pending_requests.erase(msg_type)
|
||||
|
||||
|
||||
func _handle_game_response(data: Array) -> void:
|
||||
if data.size() < 2:
|
||||
return
|
||||
var msg_type: String = data[0]
|
||||
var response_data: Variant = data[1]
|
||||
_pending_requests.erase(msg_type)
|
||||
_responses[msg_type] = response_data
|
||||
game_response.emit(msg_type, response_data)
|
||||
|
||||
|
||||
func toggle_frame_profiler(enable: bool) -> void:
|
||||
if _active_session_id < 0:
|
||||
return
|
||||
var session := get_session(_active_session_id)
|
||||
if session:
|
||||
session.toggle_profiler("mcp_frame_profiler", enable)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cakxaiej4lb6w
|
||||
10
ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd
Normal file
10
ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd
Normal file
@@ -0,0 +1,10 @@
|
||||
class_name MCPEnums
|
||||
|
||||
enum BindMode { LOCALHOST, WSL, CUSTOM }
|
||||
|
||||
static func get_mode_name(mode: BindMode) -> String:
|
||||
match mode:
|
||||
BindMode.LOCALHOST: return "Localhost"
|
||||
BindMode.WSL: return "WSL"
|
||||
BindMode.CUSTOM: return "Custom"
|
||||
_: return "Unknown"
|
||||
1
ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd.uid
Normal file
1
ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cdrfh4c15dks
|
||||
14
ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd
Normal file
14
ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd
Normal file
@@ -0,0 +1,14 @@
|
||||
@tool
|
||||
class_name MCPLog
|
||||
extends RefCounted
|
||||
|
||||
const PREFIX := "[godot-mcp] "
|
||||
|
||||
static func info(message: String) -> void:
|
||||
print(PREFIX + message)
|
||||
|
||||
static func warn(message: String) -> void:
|
||||
push_warning(PREFIX + message)
|
||||
|
||||
static func error(message: String) -> void:
|
||||
push_error(PREFIX + message)
|
||||
1
ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd.uid
Normal file
1
ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bavtmnjx74t1l
|
||||
92
ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd
Normal file
92
ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd
Normal file
@@ -0,0 +1,92 @@
|
||||
@tool
|
||||
class_name MCPLogger extends Logger
|
||||
|
||||
static var _output: PackedStringArray = []
|
||||
static var _errors: Array[Dictionary] = []
|
||||
static var _max_lines := 1000
|
||||
static var _max_errors := 100
|
||||
static var _mutex := Mutex.new()
|
||||
|
||||
|
||||
static func _static_init() -> void:
|
||||
OS.add_logger(MCPLogger.new())
|
||||
|
||||
|
||||
func _log_message(message: String, error: bool) -> void:
|
||||
_mutex.lock()
|
||||
var prefix := "[ERROR] " if error else ""
|
||||
_output.append(prefix + message)
|
||||
if _output.size() > _max_lines:
|
||||
_output.remove_at(0)
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
func _log_error(function: String, file: String, line: int, code: String,
|
||||
rationale: String, editor_notify: bool, error_type: int,
|
||||
script_backtraces: Array[ScriptBacktrace]) -> void:
|
||||
_mutex.lock()
|
||||
var msg := "[%s:%d] %s: %s" % [file.get_file(), line, code, rationale]
|
||||
_output.append("[ERROR] " + msg)
|
||||
if _output.size() > _max_lines:
|
||||
_output.remove_at(0)
|
||||
|
||||
var frames: Array[Dictionary] = []
|
||||
for backtrace in script_backtraces:
|
||||
for i in backtrace.get_frame_count():
|
||||
frames.append({
|
||||
"file": backtrace.get_frame_source(i),
|
||||
"line": backtrace.get_frame_line(i),
|
||||
"function": backtrace.get_frame_function(i),
|
||||
})
|
||||
|
||||
var error_entry := {
|
||||
"timestamp": Time.get_ticks_msec(),
|
||||
"type": code,
|
||||
"message": rationale,
|
||||
"file": file,
|
||||
"line": line,
|
||||
"function": function,
|
||||
"error_type": error_type,
|
||||
"frames": frames,
|
||||
}
|
||||
if not _is_duplicate(error_entry):
|
||||
_errors.append(error_entry)
|
||||
if _errors.size() > _max_errors:
|
||||
_errors.remove_at(0)
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
static func _is_duplicate(entry: Dictionary) -> bool:
|
||||
if _errors.is_empty():
|
||||
return false
|
||||
var last := _errors[-1]
|
||||
return (last.get("file") == entry.get("file")
|
||||
and last.get("line") == entry.get("line")
|
||||
and last.get("message") == entry.get("message")
|
||||
and last.get("type") == entry.get("type"))
|
||||
|
||||
|
||||
static func get_output() -> PackedStringArray:
|
||||
return _output
|
||||
|
||||
|
||||
static func get_errors() -> Array[Dictionary]:
|
||||
return _errors
|
||||
|
||||
|
||||
static func get_last_stack_trace() -> Array[Dictionary]:
|
||||
if _errors.is_empty():
|
||||
return []
|
||||
return _errors[-1].get("frames", [])
|
||||
|
||||
|
||||
static func clear() -> void:
|
||||
_mutex.lock()
|
||||
_output.clear()
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
static func clear_errors() -> void:
|
||||
_mutex.lock()
|
||||
_errors.clear()
|
||||
_mutex.unlock()
|
||||
1
ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd.uid
Normal file
1
ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dayisgpy78rci
|
||||
129
ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd
Normal file
129
ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd
Normal file
@@ -0,0 +1,129 @@
|
||||
@tool
|
||||
class_name MCPUtils
|
||||
extends RefCounted
|
||||
|
||||
|
||||
static func success(result: Dictionary) -> Dictionary:
|
||||
return {
|
||||
"status": "success",
|
||||
"result": result
|
||||
}
|
||||
|
||||
|
||||
static func error(code: String, message: String) -> Dictionary:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static func get_node_from_path(path: String) -> Node:
|
||||
var root := EditorInterface.get_edited_scene_root()
|
||||
if not root:
|
||||
return null
|
||||
|
||||
if path == "/root" or path == "/" or path == str(root.get_path()):
|
||||
return root
|
||||
|
||||
if path.begins_with("/root/"):
|
||||
var parts := path.split("/")
|
||||
if parts.size() >= 3:
|
||||
if parts[2] == root.name:
|
||||
var relative_path := "/".join(parts.slice(3))
|
||||
if relative_path.is_empty():
|
||||
return root
|
||||
return root.get_node_or_null(relative_path)
|
||||
|
||||
if path.begins_with("/"):
|
||||
path = path.substr(1)
|
||||
|
||||
return root.get_node_or_null(path)
|
||||
|
||||
|
||||
static func serialize_value(value: Variant) -> Variant:
|
||||
match typeof(value):
|
||||
TYPE_VECTOR2:
|
||||
return {"x": value.x, "y": value.y}
|
||||
TYPE_VECTOR2I:
|
||||
return {"x": value.x, "y": value.y}
|
||||
TYPE_VECTOR3:
|
||||
return {"x": value.x, "y": value.y, "z": value.z}
|
||||
TYPE_VECTOR3I:
|
||||
return {"x": value.x, "y": value.y, "z": value.z}
|
||||
TYPE_COLOR:
|
||||
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
|
||||
TYPE_OBJECT:
|
||||
if value == null:
|
||||
return null
|
||||
if value is Resource:
|
||||
return value.resource_path if value.resource_path else str(value)
|
||||
return str(value)
|
||||
_:
|
||||
return value
|
||||
|
||||
|
||||
static func deserialize_value(value: Variant) -> Variant:
|
||||
if value is String and value.begins_with("res://"):
|
||||
var resource := load(value)
|
||||
if resource:
|
||||
return resource
|
||||
if value is Dictionary:
|
||||
if value.has("_resource"):
|
||||
return _create_resource(value)
|
||||
if value.has("x") and value.has("y"):
|
||||
if value.has("z"):
|
||||
return Vector3(value.x, value.y, value.z)
|
||||
return Vector2(value.x, value.y)
|
||||
if value.has("r") and value.has("g") and value.has("b"):
|
||||
return Color(value.r, value.g, value.b, value.get("a", 1.0))
|
||||
return value
|
||||
|
||||
|
||||
static func _create_resource(spec: Dictionary) -> Resource:
|
||||
var resource_type: String = spec.get("_resource", "")
|
||||
if not ClassDB.class_exists(resource_type):
|
||||
MCPLog.error("Unknown resource type: %s" % resource_type)
|
||||
return null
|
||||
if not ClassDB.is_parent_class(resource_type, "Resource"):
|
||||
MCPLog.error("Type is not a Resource: %s" % resource_type)
|
||||
return null
|
||||
|
||||
var resource: Resource = ClassDB.instantiate(resource_type)
|
||||
if not resource:
|
||||
MCPLog.error("Failed to create resource: %s" % resource_type)
|
||||
return null
|
||||
|
||||
for key in spec:
|
||||
if key == "_resource":
|
||||
continue
|
||||
if key in resource:
|
||||
resource.set(key, deserialize_value(spec[key]))
|
||||
|
||||
return resource
|
||||
|
||||
|
||||
static func is_resource_path(path: String) -> bool:
|
||||
return path.begins_with("res://")
|
||||
|
||||
|
||||
static func dir_exists(path: String) -> bool:
|
||||
if path.is_empty():
|
||||
return false
|
||||
if is_resource_path(path):
|
||||
var dir := DirAccess.open("res://")
|
||||
return dir != null and dir.dir_exists(path.trim_prefix("res://"))
|
||||
return DirAccess.dir_exists_absolute(path)
|
||||
|
||||
|
||||
static func ensure_dir_exists(path: String) -> Error:
|
||||
if dir_exists(path):
|
||||
return OK
|
||||
if is_resource_path(path):
|
||||
var dir := DirAccess.open("res://")
|
||||
if not dir:
|
||||
return ERR_CANT_OPEN
|
||||
return dir.make_dir_recursive(path.trim_prefix("res://"))
|
||||
return DirAccess.make_dir_recursive_absolute(path)
|
||||
1
ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd.uid
Normal file
1
ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c63cmeafr4oqh
|
||||
@@ -0,0 +1,61 @@
|
||||
extends EngineProfiler
|
||||
class_name MCPFrameProfiler
|
||||
|
||||
const MAX_FRAMES := 300
|
||||
const MONITOR_SAMPLE_INTERVAL := 10
|
||||
|
||||
var _active := false
|
||||
var _buffer: Array[Dictionary] = []
|
||||
var _frame_index := 0
|
||||
|
||||
|
||||
func _toggle(enable: bool, _options: Array) -> void:
|
||||
_active = enable
|
||||
if enable:
|
||||
_buffer.clear()
|
||||
_frame_index = 0
|
||||
|
||||
|
||||
func _tick(frame_time: float, process_time: float, physics_time: float, physics_frame_time: float) -> void:
|
||||
if not _active:
|
||||
return
|
||||
|
||||
var entry := {
|
||||
"ft": frame_time,
|
||||
"pt": process_time,
|
||||
"pht": physics_time,
|
||||
"pft": physics_frame_time,
|
||||
"i": _frame_index,
|
||||
}
|
||||
|
||||
if _frame_index % MONITOR_SAMPLE_INTERVAL == 0:
|
||||
entry["m"] = _snapshot_monitors()
|
||||
|
||||
_buffer.append(entry)
|
||||
if _buffer.size() > MAX_FRAMES:
|
||||
_buffer.pop_front()
|
||||
|
||||
_frame_index += 1
|
||||
|
||||
|
||||
func get_buffer_data() -> Dictionary:
|
||||
return {
|
||||
"active": _active,
|
||||
"frame_count": _buffer.size(),
|
||||
"total_frames_collected": _frame_index,
|
||||
"max_fps": Engine.max_fps,
|
||||
"frames": _buffer.duplicate(),
|
||||
}
|
||||
|
||||
|
||||
func _snapshot_monitors() -> Dictionary:
|
||||
return {
|
||||
"fps": Performance.get_monitor(Performance.TIME_FPS),
|
||||
"obj_count": int(Performance.get_monitor(Performance.OBJECT_COUNT)),
|
||||
"node_count": int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)),
|
||||
"orphan_nodes": int(Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)),
|
||||
"mem_static": int(Performance.get_monitor(Performance.MEMORY_STATIC)),
|
||||
"render_objects": int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)),
|
||||
"render_draw_calls": int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)),
|
||||
"render_primitives": int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cbefgyjtpbhqq
|
||||
556
ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_game_bridge.gd
Normal file
556
ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_game_bridge.gd
Normal file
@@ -0,0 +1,556 @@
|
||||
extends Node
|
||||
class_name MCPGameBridge
|
||||
|
||||
const DEFAULT_MAX_WIDTH := 1920
|
||||
|
||||
var _logger: _MCPGameLogger
|
||||
var _profiler: MCPFrameProfiler
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if not EngineDebugger.is_active():
|
||||
return
|
||||
_logger = _MCPGameLogger.new()
|
||||
OS.add_logger(_logger)
|
||||
_profiler = MCPFrameProfiler.new()
|
||||
EngineDebugger.register_profiler("mcp_frame_profiler", _profiler)
|
||||
EngineDebugger.register_message_capture("godot_mcp", _on_debugger_message)
|
||||
MCPLog.info("Game bridge initialized")
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if EngineDebugger.is_active():
|
||||
EngineDebugger.unregister_message_capture("godot_mcp")
|
||||
if _profiler:
|
||||
EngineDebugger.unregister_profiler("mcp_frame_profiler")
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not _sequence_running or _sequence_events.is_empty():
|
||||
return
|
||||
|
||||
var elapsed := Time.get_ticks_msec() - _sequence_start_time
|
||||
|
||||
while _sequence_events.size() > 0 and _sequence_events[0].time <= elapsed:
|
||||
var seq_event: Dictionary = _sequence_events.pop_front()
|
||||
var input_event := InputEventAction.new()
|
||||
input_event.action = seq_event.action
|
||||
input_event.pressed = seq_event.is_press
|
||||
input_event.strength = 1.0 if seq_event.is_press else 0.0
|
||||
Input.parse_input_event(input_event)
|
||||
if not seq_event.is_press:
|
||||
_actions_completed += 1
|
||||
|
||||
if _sequence_events.is_empty():
|
||||
_sequence_running = false
|
||||
set_process(false)
|
||||
EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
|
||||
"completed": true,
|
||||
"actions_executed": _actions_completed,
|
||||
}])
|
||||
|
||||
|
||||
var _sequence_events: Array = []
|
||||
var _sequence_start_time: int = 0
|
||||
var _sequence_running: bool = false
|
||||
var _actions_completed: int = 0
|
||||
var _actions_total: int = 0
|
||||
|
||||
|
||||
func _on_debugger_message(message: String, data: Array) -> bool:
|
||||
match message:
|
||||
"take_screenshot":
|
||||
_take_screenshot_deferred.call_deferred(data)
|
||||
return true
|
||||
"get_debug_output":
|
||||
_handle_get_debug_output(data)
|
||||
return true
|
||||
"get_performance_metrics":
|
||||
_handle_get_performance_metrics()
|
||||
return true
|
||||
"find_nodes":
|
||||
_handle_find_nodes(data)
|
||||
return true
|
||||
"get_input_map":
|
||||
_handle_get_input_map()
|
||||
return true
|
||||
"execute_input_sequence":
|
||||
_handle_execute_input_sequence(data)
|
||||
return true
|
||||
"type_text":
|
||||
_handle_type_text(data)
|
||||
return true
|
||||
"get_profiler_data":
|
||||
_handle_get_profiler_data()
|
||||
return true
|
||||
"get_active_processes":
|
||||
_handle_get_active_processes()
|
||||
return true
|
||||
"get_signal_connections":
|
||||
_handle_get_signal_connections(data)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _take_screenshot_deferred(data: Array) -> void:
|
||||
var max_width: int = data[0] if data.size() > 0 else DEFAULT_MAX_WIDTH
|
||||
await RenderingServer.frame_post_draw
|
||||
_capture_and_send_screenshot(max_width)
|
||||
|
||||
|
||||
func _capture_and_send_screenshot(max_width: int) -> void:
|
||||
var viewport := get_viewport()
|
||||
if viewport == null:
|
||||
_send_screenshot_error("NO_VIEWPORT", "Could not get game viewport")
|
||||
return
|
||||
var image := viewport.get_texture().get_image()
|
||||
if image == null:
|
||||
_send_screenshot_error("CAPTURE_FAILED", "Failed to capture image from viewport")
|
||||
return
|
||||
if max_width > 0 and image.get_width() > max_width:
|
||||
var scale_factor := float(max_width) / float(image.get_width())
|
||||
var new_height := int(image.get_height() * scale_factor)
|
||||
image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
|
||||
var png_buffer := image.save_png_to_buffer()
|
||||
var base64 := Marshalls.raw_to_base64(png_buffer)
|
||||
EngineDebugger.send_message("godot_mcp:screenshot_result", [
|
||||
true,
|
||||
base64,
|
||||
image.get_width(),
|
||||
image.get_height(),
|
||||
""
|
||||
])
|
||||
|
||||
|
||||
func _send_screenshot_error(code: String, message: String) -> void:
|
||||
EngineDebugger.send_message("godot_mcp:screenshot_result", [
|
||||
false,
|
||||
"",
|
||||
0,
|
||||
0,
|
||||
"%s: %s" % [code, message]
|
||||
])
|
||||
|
||||
|
||||
func _handle_get_debug_output(data: Array) -> void:
|
||||
var clear: bool = data[0] if data.size() > 0 else false
|
||||
var output := _logger.get_output() if _logger else PackedStringArray()
|
||||
if clear and _logger:
|
||||
_logger.clear()
|
||||
EngineDebugger.send_message("godot_mcp:debug_output_result", [output])
|
||||
|
||||
|
||||
func _handle_find_nodes(data: Array) -> void:
|
||||
var name_pattern: String = data[0] if data.size() > 0 else ""
|
||||
var type_filter: String = data[1] if data.size() > 1 else ""
|
||||
var root_path: String = data[2] if data.size() > 2 else ""
|
||||
|
||||
var tree := get_tree()
|
||||
var scene_root := tree.current_scene if tree else null
|
||||
if not scene_root:
|
||||
EngineDebugger.send_message("godot_mcp:find_nodes_result", [[], 0, "No scene running"])
|
||||
return
|
||||
|
||||
var search_root: Node = scene_root
|
||||
if not root_path.is_empty():
|
||||
search_root = _get_node_from_path(root_path, scene_root)
|
||||
if not search_root:
|
||||
EngineDebugger.send_message("godot_mcp:find_nodes_result", [[], 0, "Root not found: " + root_path])
|
||||
return
|
||||
|
||||
var matches: Array = []
|
||||
_find_recursive(search_root, scene_root, name_pattern, type_filter, matches)
|
||||
EngineDebugger.send_message("godot_mcp:find_nodes_result", [matches, matches.size(), ""])
|
||||
|
||||
|
||||
func _get_node_from_path(path: String, scene_root: Node) -> Node:
|
||||
if path == "/" or path.is_empty():
|
||||
return scene_root
|
||||
|
||||
if path.begins_with("/root/"):
|
||||
var parts := path.split("/")
|
||||
if parts.size() >= 3 and parts[2] == scene_root.name:
|
||||
var relative := "/".join(parts.slice(3))
|
||||
if relative.is_empty():
|
||||
return scene_root
|
||||
return scene_root.get_node_or_null(relative)
|
||||
|
||||
if path.begins_with("/"):
|
||||
path = path.substr(1)
|
||||
|
||||
return scene_root.get_node_or_null(path)
|
||||
|
||||
|
||||
func _find_recursive(node: Node, scene_root: Node, name_pattern: String, type_filter: String, results: Array) -> void:
|
||||
var name_matches := name_pattern.is_empty() or node.name.matchn(name_pattern)
|
||||
var type_matches := type_filter.is_empty() or node.is_class(type_filter)
|
||||
|
||||
if name_matches and type_matches:
|
||||
var path := "/root/" + scene_root.name
|
||||
var relative := scene_root.get_path_to(node)
|
||||
if relative != NodePath("."):
|
||||
path += "/" + str(relative)
|
||||
results.append({"path": path, "type": node.get_class()})
|
||||
|
||||
for child in node.get_children():
|
||||
_find_recursive(child, scene_root, name_pattern, type_filter, results)
|
||||
|
||||
|
||||
func _handle_get_performance_metrics() -> void:
|
||||
var metrics := {
|
||||
"fps": Performance.get_monitor(Performance.TIME_FPS),
|
||||
"frame_time_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
|
||||
"physics_time_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0,
|
||||
"navigation_time_ms": Performance.get_monitor(Performance.TIME_NAVIGATION_PROCESS) * 1000.0,
|
||||
"render_objects": int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)),
|
||||
"render_draw_calls": int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)),
|
||||
"render_primitives": int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)),
|
||||
"render_video_mem": int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)),
|
||||
"render_texture_mem": int(Performance.get_monitor(Performance.RENDER_TEXTURE_MEM_USED)),
|
||||
"render_buffer_mem": int(Performance.get_monitor(Performance.RENDER_BUFFER_MEM_USED)),
|
||||
"physics_2d_active_objects": int(Performance.get_monitor(Performance.PHYSICS_2D_ACTIVE_OBJECTS)),
|
||||
"physics_2d_collision_pairs": int(Performance.get_monitor(Performance.PHYSICS_2D_COLLISION_PAIRS)),
|
||||
"physics_2d_island_count": int(Performance.get_monitor(Performance.PHYSICS_2D_ISLAND_COUNT)),
|
||||
"physics_3d_active_objects": int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS)),
|
||||
"physics_3d_collision_pairs": int(Performance.get_monitor(Performance.PHYSICS_3D_COLLISION_PAIRS)),
|
||||
"physics_3d_island_count": int(Performance.get_monitor(Performance.PHYSICS_3D_ISLAND_COUNT)),
|
||||
"audio_output_latency": Performance.get_monitor(Performance.AUDIO_OUTPUT_LATENCY),
|
||||
"object_count": int(Performance.get_monitor(Performance.OBJECT_COUNT)),
|
||||
"object_resource_count": int(Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT)),
|
||||
"object_node_count": int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)),
|
||||
"object_orphan_node_count": int(Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)),
|
||||
"memory_static": int(Performance.get_monitor(Performance.MEMORY_STATIC)),
|
||||
"memory_static_max": int(Performance.get_monitor(Performance.MEMORY_STATIC_MAX)),
|
||||
"memory_msg_buffer_max": int(Performance.get_monitor(Performance.MEMORY_MESSAGE_BUFFER_MAX)),
|
||||
"navigation_active_maps": int(Performance.get_monitor(Performance.NAVIGATION_ACTIVE_MAPS)),
|
||||
"navigation_region_count": int(Performance.get_monitor(Performance.NAVIGATION_REGION_COUNT)),
|
||||
"navigation_agent_count": int(Performance.get_monitor(Performance.NAVIGATION_AGENT_COUNT)),
|
||||
"navigation_link_count": int(Performance.get_monitor(Performance.NAVIGATION_LINK_COUNT)),
|
||||
"navigation_polygon_count": int(Performance.get_monitor(Performance.NAVIGATION_POLYGON_COUNT)),
|
||||
"navigation_edge_count": int(Performance.get_monitor(Performance.NAVIGATION_EDGE_COUNT)),
|
||||
"navigation_edge_merge_count": int(Performance.get_monitor(Performance.NAVIGATION_EDGE_MERGE_COUNT)),
|
||||
"navigation_edge_connection_count": int(Performance.get_monitor(Performance.NAVIGATION_EDGE_CONNECTION_COUNT)),
|
||||
"navigation_edge_free_count": int(Performance.get_monitor(Performance.NAVIGATION_EDGE_FREE_COUNT)),
|
||||
"navigation_obstacle_count": int(Performance.get_monitor(Performance.NAVIGATION_OBSTACLE_COUNT)),
|
||||
"pipeline_compilations_canvas": int(Performance.get_monitor(Performance.PIPELINE_COMPILATIONS_CANVAS)),
|
||||
"pipeline_compilations_mesh": int(Performance.get_monitor(Performance.PIPELINE_COMPILATIONS_MESH)),
|
||||
"pipeline_compilations_surface": int(Performance.get_monitor(Performance.PIPELINE_COMPILATIONS_SURFACE)),
|
||||
"pipeline_compilations_draw": int(Performance.get_monitor(Performance.PIPELINE_COMPILATIONS_DRAW)),
|
||||
"pipeline_compilations_specialization": int(Performance.get_monitor(Performance.PIPELINE_COMPILATIONS_SPECIALIZATION)),
|
||||
}
|
||||
|
||||
var rid := get_viewport().get_viewport_rid()
|
||||
metrics["viewport_render_cpu_ms"] = RenderingServer.viewport_get_measured_render_time_cpu(rid) + RenderingServer.viewport_get_measured_render_time_gpu(rid)
|
||||
metrics["viewport_render_gpu_ms"] = RenderingServer.viewport_get_measured_render_time_gpu(rid)
|
||||
|
||||
EngineDebugger.send_message("godot_mcp:performance_metrics_result", [metrics])
|
||||
|
||||
|
||||
func _handle_get_profiler_data() -> void:
|
||||
var data := _profiler.get_buffer_data() if _profiler else {}
|
||||
EngineDebugger.send_message("godot_mcp:game_response", ["get_profiler_data", data])
|
||||
|
||||
|
||||
func _handle_get_active_processes() -> void:
|
||||
var tree := get_tree()
|
||||
var scene_root := tree.current_scene if tree else null
|
||||
if not scene_root:
|
||||
EngineDebugger.send_message("godot_mcp:game_response", ["get_active_processes", {"processes": []}])
|
||||
return
|
||||
|
||||
var script_map: Dictionary = {}
|
||||
_collect_processes(scene_root, scene_root, script_map)
|
||||
|
||||
var processes: Array = []
|
||||
for script_path in script_map:
|
||||
processes.append(script_map[script_path])
|
||||
|
||||
processes.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return a.instance_count > b.instance_count
|
||||
)
|
||||
|
||||
EngineDebugger.send_message("godot_mcp:game_response", ["get_active_processes", {"processes": processes}])
|
||||
|
||||
|
||||
func _collect_processes(node: Node, scene_root: Node, script_map: Dictionary) -> void:
|
||||
var is_proc := node.is_processing()
|
||||
var is_phys := node.is_physics_processing()
|
||||
|
||||
if is_proc or is_phys:
|
||||
var script_path := ""
|
||||
var script := node.get_script()
|
||||
if script and script is Script:
|
||||
script_path = script.resource_path
|
||||
if script_path.is_empty():
|
||||
script_path = node.get_class()
|
||||
|
||||
if not script_map.has(script_path):
|
||||
script_map[script_path] = {
|
||||
"script_path": script_path,
|
||||
"has_process": false,
|
||||
"has_physics_process": false,
|
||||
"instance_count": 0,
|
||||
"example_paths": [],
|
||||
}
|
||||
|
||||
var entry: Dictionary = script_map[script_path]
|
||||
if is_proc:
|
||||
entry.has_process = true
|
||||
if is_phys:
|
||||
entry.has_physics_process = true
|
||||
entry.instance_count += 1
|
||||
if entry.example_paths.size() < 3:
|
||||
var path := "/root/" + scene_root.name
|
||||
var relative := scene_root.get_path_to(node)
|
||||
if relative != NodePath("."):
|
||||
path += "/" + str(relative)
|
||||
entry.example_paths.append(path)
|
||||
|
||||
for child in node.get_children():
|
||||
_collect_processes(child, scene_root, script_map)
|
||||
|
||||
|
||||
func _handle_get_signal_connections(data: Array) -> void:
|
||||
var node_path: String = data[0] if data.size() > 0 else ""
|
||||
|
||||
var tree := get_tree()
|
||||
var scene_root := tree.current_scene if tree else null
|
||||
if not scene_root:
|
||||
EngineDebugger.send_message("godot_mcp:game_response", ["get_signal_connections", {"connections": []}])
|
||||
return
|
||||
|
||||
var search_root: Node = scene_root
|
||||
if not node_path.is_empty():
|
||||
search_root = _get_node_from_path(node_path, scene_root)
|
||||
if not search_root:
|
||||
EngineDebugger.send_message("godot_mcp:game_response", ["get_signal_connections", {"connections": [], "error": "Node not found: " + node_path}])
|
||||
return
|
||||
|
||||
var connections: Array = []
|
||||
_collect_signal_connections(search_root, scene_root, connections, 0)
|
||||
|
||||
EngineDebugger.send_message("godot_mcp:game_response", ["get_signal_connections", {"connections": connections}])
|
||||
|
||||
|
||||
const MAX_SIGNAL_CONNECTIONS := 200
|
||||
const MAX_SIGNAL_DEPTH := 20
|
||||
|
||||
|
||||
func _collect_signal_connections(node: Node, scene_root: Node, connections: Array, depth: int) -> void:
|
||||
if connections.size() >= MAX_SIGNAL_CONNECTIONS or depth > MAX_SIGNAL_DEPTH:
|
||||
return
|
||||
|
||||
var source_path := _node_path_string(node, scene_root)
|
||||
|
||||
for sig_info in node.get_signal_list():
|
||||
var sig_name: String = sig_info.name
|
||||
for conn in node.get_signal_connection_list(sig_name):
|
||||
if connections.size() >= MAX_SIGNAL_CONNECTIONS:
|
||||
return
|
||||
var target: Object = conn.callable.get_object()
|
||||
var target_path := ""
|
||||
if target is Node:
|
||||
target_path = _node_path_string(target as Node, scene_root)
|
||||
else:
|
||||
target_path = str(target)
|
||||
connections.append({
|
||||
"source_path": source_path,
|
||||
"signal_name": sig_name,
|
||||
"target_path": target_path,
|
||||
"method_name": conn.callable.get_method(),
|
||||
})
|
||||
|
||||
for child in node.get_children():
|
||||
if connections.size() >= MAX_SIGNAL_CONNECTIONS:
|
||||
return
|
||||
_collect_signal_connections(child, scene_root, connections, depth + 1)
|
||||
|
||||
|
||||
func _node_path_string(node: Node, scene_root: Node) -> String:
|
||||
var path := "/root/" + scene_root.name
|
||||
var relative := scene_root.get_path_to(node)
|
||||
if relative != NodePath("."):
|
||||
path += "/" + str(relative)
|
||||
return path
|
||||
|
||||
|
||||
class _MCPGameLogger extends Logger:
|
||||
var _output: PackedStringArray = []
|
||||
var _max_lines := 1000
|
||||
var _mutex := Mutex.new()
|
||||
|
||||
func _log_message(message: String, error: bool) -> void:
|
||||
_mutex.lock()
|
||||
var prefix := "[ERROR] " if error else ""
|
||||
_output.append(prefix + message)
|
||||
if _output.size() > _max_lines:
|
||||
_output.remove_at(0)
|
||||
_mutex.unlock()
|
||||
|
||||
func _log_error(function: String, file: String, line: int, code: String,
|
||||
rationale: String, editor_notify: bool, error_type: int,
|
||||
script_backtraces: Array[ScriptBacktrace]) -> void:
|
||||
_mutex.lock()
|
||||
var msg := "[%s:%d] %s: %s" % [file.get_file(), line, code, rationale]
|
||||
_output.append("[ERROR] " + msg)
|
||||
if _output.size() > _max_lines:
|
||||
_output.remove_at(0)
|
||||
_mutex.unlock()
|
||||
|
||||
func get_output() -> PackedStringArray:
|
||||
return _output
|
||||
|
||||
func clear() -> void:
|
||||
_mutex.lock()
|
||||
_output.clear()
|
||||
_mutex.unlock()
|
||||
|
||||
|
||||
func _handle_get_input_map() -> void:
|
||||
var actions: Array = []
|
||||
for action_name in InputMap.get_actions():
|
||||
if action_name.begins_with("ui_"):
|
||||
continue
|
||||
var events := InputMap.action_get_events(action_name)
|
||||
var event_strings: Array = []
|
||||
for event in events:
|
||||
event_strings.append(_event_to_string(event))
|
||||
actions.append({
|
||||
"name": action_name,
|
||||
"events": event_strings,
|
||||
})
|
||||
EngineDebugger.send_message("godot_mcp:input_map_result", [actions, ""])
|
||||
|
||||
|
||||
func _event_to_string(event: InputEvent) -> String:
|
||||
if event is InputEventKey:
|
||||
var key_event := event as InputEventKey
|
||||
var key_name := OS.get_keycode_string(key_event.keycode)
|
||||
if key_event.ctrl_pressed:
|
||||
key_name = "Ctrl+" + key_name
|
||||
if key_event.alt_pressed:
|
||||
key_name = "Alt+" + key_name
|
||||
if key_event.shift_pressed:
|
||||
key_name = "Shift+" + key_name
|
||||
return key_name
|
||||
elif event is InputEventMouseButton:
|
||||
var mouse_event := event as InputEventMouseButton
|
||||
match mouse_event.button_index:
|
||||
MOUSE_BUTTON_LEFT:
|
||||
return "Mouse Left"
|
||||
MOUSE_BUTTON_RIGHT:
|
||||
return "Mouse Right"
|
||||
MOUSE_BUTTON_MIDDLE:
|
||||
return "Mouse Middle"
|
||||
_:
|
||||
return "Mouse Button %d" % mouse_event.button_index
|
||||
elif event is InputEventJoypadButton:
|
||||
var joy_event := event as InputEventJoypadButton
|
||||
return "Joypad Button %d" % joy_event.button_index
|
||||
elif event is InputEventJoypadMotion:
|
||||
var joy_motion := event as InputEventJoypadMotion
|
||||
return "Joypad Axis %d" % joy_motion.axis
|
||||
return event.as_text()
|
||||
|
||||
|
||||
func _handle_execute_input_sequence(data: Array) -> void:
|
||||
var inputs: Array = data[0] if data.size() > 0 else []
|
||||
|
||||
if inputs.is_empty():
|
||||
EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
|
||||
"error": "No inputs provided",
|
||||
}])
|
||||
return
|
||||
|
||||
_sequence_events.clear()
|
||||
_actions_completed = 0
|
||||
_actions_total = inputs.size()
|
||||
|
||||
for input in inputs:
|
||||
var action_name: String = input.get("action_name", "")
|
||||
var start_ms: int = int(input.get("start_ms", 0))
|
||||
var duration_ms: int = int(input.get("duration_ms", 0))
|
||||
|
||||
if action_name.is_empty():
|
||||
continue
|
||||
|
||||
if not InputMap.has_action(action_name):
|
||||
EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
|
||||
"error": "Unknown action: %s" % action_name,
|
||||
}])
|
||||
return
|
||||
|
||||
_sequence_events.append({
|
||||
"time": start_ms,
|
||||
"action": action_name,
|
||||
"is_press": true,
|
||||
})
|
||||
_sequence_events.append({
|
||||
"time": start_ms + duration_ms,
|
||||
"action": action_name,
|
||||
"is_press": false,
|
||||
})
|
||||
|
||||
_sequence_events.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return a.time < b.time
|
||||
)
|
||||
|
||||
_sequence_start_time = Time.get_ticks_msec()
|
||||
_sequence_running = true
|
||||
set_process(true)
|
||||
|
||||
|
||||
func _handle_type_text(data: Array) -> void:
|
||||
var text: String = data[0] if data.size() > 0 else ""
|
||||
var delay_ms: int = int(data[1]) if data.size() > 1 else 50
|
||||
var submit: bool = data[2] if data.size() > 2 else false
|
||||
|
||||
if text.is_empty():
|
||||
EngineDebugger.send_message("godot_mcp:type_text_result", [{
|
||||
"error": "No text provided",
|
||||
}])
|
||||
return
|
||||
|
||||
_type_text_async(text, delay_ms, submit)
|
||||
|
||||
|
||||
func _type_text_async(text: String, delay_ms: int, submit: bool) -> void:
|
||||
for i in text.length():
|
||||
var char_code := text.unicode_at(i)
|
||||
|
||||
var press := InputEventKey.new()
|
||||
press.keycode = char_code
|
||||
press.unicode = char_code
|
||||
press.pressed = true
|
||||
Input.parse_input_event(press)
|
||||
|
||||
var release := InputEventKey.new()
|
||||
release.keycode = char_code
|
||||
release.unicode = char_code
|
||||
release.pressed = false
|
||||
Input.parse_input_event(release)
|
||||
|
||||
if delay_ms > 0 and i < text.length() - 1:
|
||||
await get_tree().create_timer(delay_ms / 1000.0).timeout
|
||||
|
||||
if submit:
|
||||
if delay_ms > 0:
|
||||
await get_tree().create_timer(delay_ms / 1000.0).timeout
|
||||
|
||||
var enter_press := InputEventKey.new()
|
||||
enter_press.keycode = KEY_ENTER
|
||||
enter_press.physical_keycode = KEY_ENTER
|
||||
enter_press.pressed = true
|
||||
Input.parse_input_event(enter_press)
|
||||
|
||||
var enter_release := InputEventKey.new()
|
||||
enter_release.keycode = KEY_ENTER
|
||||
enter_release.physical_keycode = KEY_ENTER
|
||||
enter_release.pressed = false
|
||||
Input.parse_input_event(enter_release)
|
||||
|
||||
EngineDebugger.send_message("godot_mcp:type_text_result", [{
|
||||
"completed": true,
|
||||
"chars_typed": text.length(),
|
||||
"submitted": submit,
|
||||
}])
|
||||
@@ -0,0 +1 @@
|
||||
uid://c606pf78f127d
|
||||
8
ruf-der-pilze/addons/godot_mcp/plugin.cfg
Normal file
8
ruf-der-pilze/addons/godot_mcp/plugin.cfg
Normal file
@@ -0,0 +1,8 @@
|
||||
[plugin]
|
||||
|
||||
name="Godot MCP"
|
||||
description="Model Context Protocol server for AI assistant integration"
|
||||
author="godot-mcp"
|
||||
version="2.17.0"
|
||||
script="plugin.gd"
|
||||
godot_version_min="4.5"
|
||||
303
ruf-der-pilze/addons/godot_mcp/plugin.gd
Normal file
303
ruf-der-pilze/addons/godot_mcp/plugin.gd
Normal file
@@ -0,0 +1,303 @@
|
||||
@tool
|
||||
extends EditorPlugin
|
||||
|
||||
const WebSocketServer := preload("res://addons/godot_mcp/websocket_server.gd")
|
||||
const CommandRouter := preload("res://addons/godot_mcp/command_router.gd")
|
||||
const StatusPanel := preload("res://addons/godot_mcp/ui/status_panel.tscn")
|
||||
const MCPDebuggerPlugin := preload("res://addons/godot_mcp/core/mcp_debugger_plugin.gd")
|
||||
|
||||
const GAME_BRIDGE_AUTOLOAD := "MCPGameBridge"
|
||||
const GAME_BRIDGE_PATH := "res://addons/godot_mcp/game_bridge/mcp_game_bridge.gd"
|
||||
|
||||
const SETTING_BIND_MODE := "godot_mcp/bind_mode"
|
||||
const SETTING_CUSTOM_BIND_IP := "godot_mcp/custom_bind_ip"
|
||||
const SETTING_PORT_OVERRIDE_ENABLED := "godot_mcp/port_override_enabled"
|
||||
const SETTING_PORT_OVERRIDE := "godot_mcp/port_override"
|
||||
|
||||
var _websocket_server: WebSocketServer
|
||||
var _command_router: CommandRouter
|
||||
var _status_panel: Control
|
||||
var _debugger_plugin: MCPDebuggerPlugin
|
||||
var _restart_timer: Timer
|
||||
|
||||
var _current_bind_address := MCPConstants.LOCALHOST_BIND_ADDRESS
|
||||
var _current_bind_mode: MCPEnums.BindMode = MCPEnums.BindMode.LOCALHOST
|
||||
|
||||
|
||||
func _enter_tree() -> void:
|
||||
_command_router = CommandRouter.new()
|
||||
_command_router.setup(self)
|
||||
|
||||
_websocket_server = WebSocketServer.new()
|
||||
_websocket_server.command_received.connect(_on_command_received)
|
||||
_websocket_server.client_connected.connect(_on_client_connected)
|
||||
_websocket_server.client_disconnected.connect(_on_client_disconnected)
|
||||
add_child(_websocket_server)
|
||||
|
||||
_status_panel = StatusPanel.instantiate()
|
||||
add_control_to_bottom_panel(_status_panel, "MCP")
|
||||
|
||||
_debugger_plugin = MCPDebuggerPlugin.new()
|
||||
add_debugger_plugin(_debugger_plugin)
|
||||
|
||||
_restart_timer = Timer.new()
|
||||
_restart_timer.one_shot = true
|
||||
_restart_timer.timeout.connect(_do_restart_server)
|
||||
add_child(_restart_timer)
|
||||
|
||||
_ensure_game_bridge_autoload()
|
||||
_ensure_bind_settings()
|
||||
_setup_bind_ui()
|
||||
_setup_version_display()
|
||||
_apply_bind_settings(true)
|
||||
MCPLog.info("Plugin initialized")
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if _restart_timer:
|
||||
_restart_timer.stop()
|
||||
_restart_timer.queue_free()
|
||||
|
||||
if _status_panel:
|
||||
remove_control_from_bottom_panel(_status_panel)
|
||||
_status_panel.queue_free()
|
||||
|
||||
if _websocket_server:
|
||||
_websocket_server.stop_server()
|
||||
_websocket_server.queue_free()
|
||||
|
||||
if _debugger_plugin:
|
||||
remove_debugger_plugin(_debugger_plugin)
|
||||
_debugger_plugin = null
|
||||
|
||||
if _command_router:
|
||||
_command_router = null # RefCounted - freed automatically
|
||||
|
||||
MCPLog.info("Plugin disabled")
|
||||
|
||||
|
||||
func _ensure_bind_settings() -> void:
|
||||
if not ProjectSettings.has_setting(SETTING_BIND_MODE):
|
||||
ProjectSettings.set_setting(SETTING_BIND_MODE, MCPEnums.BindMode.LOCALHOST)
|
||||
if not ProjectSettings.has_setting(SETTING_CUSTOM_BIND_IP):
|
||||
ProjectSettings.set_setting(SETTING_CUSTOM_BIND_IP, "")
|
||||
if not ProjectSettings.has_setting(SETTING_PORT_OVERRIDE_ENABLED):
|
||||
ProjectSettings.set_setting(SETTING_PORT_OVERRIDE_ENABLED, false)
|
||||
if not ProjectSettings.has_setting(SETTING_PORT_OVERRIDE):
|
||||
ProjectSettings.set_setting(SETTING_PORT_OVERRIDE, WebSocketServer.DEFAULT_PORT)
|
||||
ProjectSettings.save()
|
||||
|
||||
|
||||
func _setup_bind_ui() -> void:
|
||||
if not _status_panel:
|
||||
return
|
||||
if _status_panel.has_method("set_config"):
|
||||
_status_panel.set_config(_get_bind_mode(), _get_custom_bind_ip(), _get_port_override_enabled(), _get_port_override())
|
||||
if _status_panel.has_signal("config_applied") and not _status_panel.config_applied.is_connected(_on_config_applied):
|
||||
_status_panel.config_applied.connect(_on_config_applied)
|
||||
|
||||
|
||||
func _get_bind_mode() -> MCPEnums.BindMode:
|
||||
return ProjectSettings.get_setting(SETTING_BIND_MODE, MCPEnums.BindMode.LOCALHOST) as MCPEnums.BindMode
|
||||
|
||||
|
||||
func _get_custom_bind_ip() -> String:
|
||||
return str(ProjectSettings.get_setting(SETTING_CUSTOM_BIND_IP, ""))
|
||||
|
||||
|
||||
func _get_port_override_enabled() -> bool:
|
||||
return bool(ProjectSettings.get_setting(SETTING_PORT_OVERRIDE_ENABLED, false))
|
||||
|
||||
|
||||
func _get_port_override() -> int:
|
||||
var raw_value := ProjectSettings.get_setting(SETTING_PORT_OVERRIDE, WebSocketServer.DEFAULT_PORT)
|
||||
var port := int(raw_value)
|
||||
if port < MCPConstants.PORT_MIN or port > MCPConstants.PORT_MAX:
|
||||
MCPLog.warn("Invalid port override '%s'; falling back to default port %d" % [str(raw_value), WebSocketServer.DEFAULT_PORT])
|
||||
return WebSocketServer.DEFAULT_PORT
|
||||
return port
|
||||
|
||||
|
||||
func _get_listen_port() -> int:
|
||||
return _get_port_override() if _get_port_override_enabled() else WebSocketServer.DEFAULT_PORT
|
||||
|
||||
|
||||
func _resolve_bind_address() -> String:
|
||||
match _get_bind_mode():
|
||||
MCPEnums.BindMode.WSL:
|
||||
var ip := _get_wsl_vethernet_ipv4()
|
||||
if ip.is_empty():
|
||||
MCPLog.warn("WSL bind mode selected but vEthernet (WSL) IPv4 was not found; falling back to %s" % MCPConstants.LOCALHOST_BIND_ADDRESS)
|
||||
return MCPConstants.LOCALHOST_BIND_ADDRESS
|
||||
return ip
|
||||
MCPEnums.BindMode.CUSTOM:
|
||||
var ip := _get_custom_bind_ip().strip_edges()
|
||||
if ip.is_empty():
|
||||
MCPLog.warn("Custom bind mode selected but no IP was configured; falling back to %s" % MCPConstants.LOCALHOST_BIND_ADDRESS)
|
||||
return MCPConstants.LOCALHOST_BIND_ADDRESS
|
||||
if not _is_valid_ipv4(ip):
|
||||
MCPLog.warn("Custom bind mode selected but IP '%s' is not a valid IPv4 address; falling back to %s" % [ip, MCPConstants.LOCALHOST_BIND_ADDRESS])
|
||||
return MCPConstants.LOCALHOST_BIND_ADDRESS
|
||||
return ip
|
||||
_:
|
||||
return MCPConstants.LOCALHOST_BIND_ADDRESS
|
||||
|
||||
|
||||
func _is_valid_ipv4(ip: String) -> bool:
|
||||
var s := ip.strip_edges()
|
||||
if s.is_empty():
|
||||
return false
|
||||
var parts := s.split(".")
|
||||
if parts.size() != 4:
|
||||
return false
|
||||
for p in parts:
|
||||
if p.is_empty() or not p.is_valid_int():
|
||||
return false
|
||||
var n := int(p)
|
||||
if n < 0 or n > 255:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _is_valid_bind_address(ip: String) -> bool:
|
||||
if ip == "0.0.0.0" or ip == "127.0.0.1" or ip == "::" or ip == "::1":
|
||||
return true
|
||||
|
||||
var local_ips := IP.get_local_addresses()
|
||||
return ip in local_ips
|
||||
|
||||
|
||||
func _get_wsl_vethernet_ipv4() -> String:
|
||||
# Autodetect "vEthernet (WSL)" IPv4 via PowerShell (Windows only).
|
||||
if OS.get_name() != "Windows":
|
||||
return ""
|
||||
|
||||
var output := []
|
||||
# Use ErrorAction Stop + catch so failures return an empty string and don't emit noisy errors.
|
||||
# Match any adapter alias that contains "WSL" to be resilient to name variations.
|
||||
# Note: The wildcard pattern 'vEthernet*WSL*' provides flexibility but may match
|
||||
# unexpected adapters in custom network configurations. Document expected adapter names.
|
||||
# SECURITY NOTE: Keep this PowerShell command as a fixed string literal. Do NOT concatenate
|
||||
# user input, project settings, environment variables, or any other external data into it,
|
||||
# as that could introduce command injection vulnerabilities. If dynamic behavior is needed,
|
||||
# implement strict validation and avoid direct string interpolation into PowerShell.
|
||||
var cmd := "try { $ip = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.InterfaceAlias -like 'vEthernet*WSL*' } | Select-Object -First 1 -ExpandProperty IPAddress); if ($ip) { $ip } else { '' } } catch { '' }"
|
||||
var args := ["-NoProfile", "-Command", cmd]
|
||||
var code := OS.execute("powershell", args, output, false)
|
||||
if code != 0 or output.is_empty():
|
||||
return ""
|
||||
|
||||
var text := String(output[0]).replace("\r", "")
|
||||
for line in text.split("\n"):
|
||||
var candidate := String(line).strip_edges()
|
||||
if _is_valid_ipv4(candidate):
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
func _restart_server() -> void:
|
||||
# Debounce: stop any pending restart and schedule a new one
|
||||
if _websocket_server:
|
||||
_websocket_server.stop_server()
|
||||
_restart_timer.start(0.1)
|
||||
|
||||
|
||||
func _do_restart_server() -> void:
|
||||
if not is_inside_tree() or not _websocket_server:
|
||||
return
|
||||
|
||||
var bind := _resolve_bind_address()
|
||||
|
||||
# Verify IP is local
|
||||
if not _is_valid_bind_address(bind):
|
||||
MCPLog.error("IP '%s' is not assigned to any local network interface. Aborting bind." % bind)
|
||||
MCPLog.warn("Please check your IP configuration and local network interfaces.")
|
||||
_update_status("Error: IP %s not found on this machine" % bind)
|
||||
return
|
||||
|
||||
var port := _get_listen_port()
|
||||
_current_bind_address = bind
|
||||
_current_bind_mode = _get_bind_mode()
|
||||
var mode_name := MCPEnums.get_mode_name(_current_bind_mode)
|
||||
|
||||
var err := _websocket_server.start_server(port, bind)
|
||||
if err != OK:
|
||||
_update_status("Failed to bind %s:%d (%s)" % [bind, port, error_string(err)])
|
||||
return
|
||||
|
||||
_update_status("Waiting for connection... (bind %s:%d [%s])" % [bind, port, mode_name])
|
||||
MCPLog.info("Server listening on %s:%d [%s]" % [bind, port, mode_name])
|
||||
|
||||
|
||||
func _apply_bind_settings(restart: bool) -> void:
|
||||
_current_bind_address = _resolve_bind_address()
|
||||
_current_bind_mode = _get_bind_mode()
|
||||
if restart:
|
||||
_restart_server()
|
||||
else:
|
||||
_update_status("Waiting for connection... (bind %s:%d [%s])" % [_current_bind_address, _get_listen_port(), MCPEnums.get_mode_name(_current_bind_mode)])
|
||||
|
||||
|
||||
func _on_config_applied(config: Dictionary) -> void:
|
||||
ProjectSettings.set_setting(SETTING_BIND_MODE, config.get("bind_mode", MCPEnums.BindMode.LOCALHOST))
|
||||
ProjectSettings.set_setting(SETTING_CUSTOM_BIND_IP, str(config.get("custom_ip", "")))
|
||||
ProjectSettings.set_setting(SETTING_PORT_OVERRIDE_ENABLED, bool(config.get("port_override_enabled", false)))
|
||||
ProjectSettings.set_setting(SETTING_PORT_OVERRIDE, int(config.get("port_override", WebSocketServer.DEFAULT_PORT)))
|
||||
ProjectSettings.save()
|
||||
_apply_bind_settings(true)
|
||||
|
||||
|
||||
func _ensure_game_bridge_autoload() -> void:
|
||||
if not ProjectSettings.has_setting("autoload/" + GAME_BRIDGE_AUTOLOAD):
|
||||
ProjectSettings.set_setting("autoload/" + GAME_BRIDGE_AUTOLOAD, GAME_BRIDGE_PATH)
|
||||
ProjectSettings.save()
|
||||
MCPLog.info("Added MCPGameBridge autoload")
|
||||
|
||||
|
||||
func get_debugger_plugin() -> MCPDebuggerPlugin:
|
||||
return _debugger_plugin
|
||||
|
||||
|
||||
func _on_command_received(id: String, command: String, params: Dictionary) -> void:
|
||||
var response = await _command_router.handle_command(command, params)
|
||||
response["id"] = id
|
||||
_websocket_server.send_response(response)
|
||||
|
||||
|
||||
func _on_client_connected() -> void:
|
||||
var host_info := ""
|
||||
if _websocket_server.get_connected_host():
|
||||
host_info = " from %s:%d" % [_websocket_server.get_connected_host(), _websocket_server.get_connected_port()]
|
||||
var bind_info := "(%s: %s:%d)" % [MCPEnums.get_mode_name(_current_bind_mode), _current_bind_address, _get_listen_port()]
|
||||
_update_status("Connected%s %s" % [host_info, bind_info])
|
||||
MCPLog.info("Client connected%s %s" % [host_info, bind_info])
|
||||
|
||||
|
||||
func _on_client_disconnected() -> void:
|
||||
_update_status("Disconnected")
|
||||
if _status_panel and _status_panel.has_method("clear_server_version"):
|
||||
_status_panel.clear_server_version()
|
||||
MCPLog.info("Client disconnected")
|
||||
|
||||
|
||||
func _update_status(status: String) -> void:
|
||||
if _status_panel and _status_panel.has_method("set_status"):
|
||||
_status_panel.set_status(status)
|
||||
|
||||
|
||||
func _setup_version_display() -> void:
|
||||
if _status_panel and _status_panel.has_method("set_addon_version"):
|
||||
_status_panel.set_addon_version(_get_addon_version())
|
||||
|
||||
|
||||
func _get_addon_version() -> String:
|
||||
var config := ConfigFile.new()
|
||||
var err := config.load("res://addons/godot_mcp/plugin.cfg")
|
||||
if err == OK:
|
||||
return config.get_value("plugin", "version", "")
|
||||
return ""
|
||||
|
||||
|
||||
func on_server_version_received(version: String) -> void:
|
||||
if _status_panel and _status_panel.has_method("set_server_version"):
|
||||
_status_panel.set_server_version(version)
|
||||
1
ruf-der-pilze/addons/godot_mcp/plugin.gd.uid
Normal file
1
ruf-der-pilze/addons/godot_mcp/plugin.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bhl2ru4tj4sa8
|
||||
226
ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd
Normal file
226
ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd
Normal file
@@ -0,0 +1,226 @@
|
||||
@tool
|
||||
extends Control
|
||||
|
||||
signal config_applied(config: Dictionary)
|
||||
|
||||
|
||||
func _get_minimum_size() -> Vector2:
|
||||
return Vector2.ZERO
|
||||
|
||||
@onready var status_label: Label = $MarginContainer/VBoxContainer/StatusRow/StatusLabel
|
||||
@onready var status_icon: ColorRect = $MarginContainer/VBoxContainer/StatusRow/StatusIcon
|
||||
@onready var version_label: Label = $MarginContainer/VBoxContainer/StatusRow/VersionLabel
|
||||
@onready var bind_mode_option: OptionButton = $MarginContainer/VBoxContainer/SettingsGrid/BindModeOption
|
||||
@onready var custom_ip_label: Label = $MarginContainer/VBoxContainer/SettingsGrid/CustomIpLabel
|
||||
@onready var custom_ip_edit: LineEdit = $MarginContainer/VBoxContainer/SettingsGrid/CustomIpEdit
|
||||
@onready var port_override_label: Label = $MarginContainer/VBoxContainer/SettingsGrid/PortOverrideLabel
|
||||
@onready var port_override_enabled: CheckBox = $MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls/PortOverrideEnabled
|
||||
@onready var port_override_spin: SpinBox = $MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls/PortOverrideSpin
|
||||
@onready var apply_button: Button = $MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls/ApplyButton
|
||||
|
||||
var _addon_version: String = ""
|
||||
|
||||
var _updating_ui := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if bind_mode_option:
|
||||
_updating_ui = true
|
||||
bind_mode_option.clear()
|
||||
bind_mode_option.add_item("Localhost", 0)
|
||||
bind_mode_option.add_item("WSL", 1)
|
||||
bind_mode_option.add_item("Custom", 2)
|
||||
bind_mode_option.selected = 0
|
||||
_updating_ui = false
|
||||
bind_mode_option.item_selected.connect(_on_bind_mode_selected)
|
||||
|
||||
if apply_button:
|
||||
apply_button.pressed.connect(_on_apply_pressed)
|
||||
|
||||
if port_override_enabled:
|
||||
port_override_enabled.toggled.connect(_on_port_override_toggled)
|
||||
if port_override_spin:
|
||||
port_override_spin.value = 6550
|
||||
|
||||
# Keyboard navigation / focus
|
||||
_for_control_focus(bind_mode_option)
|
||||
_for_control_focus(custom_ip_edit)
|
||||
_for_control_focus(port_override_enabled)
|
||||
_for_control_focus(port_override_spin)
|
||||
_for_control_focus(apply_button)
|
||||
|
||||
# Set up focus chain: each control points to the next in sequence
|
||||
if bind_mode_option and (custom_ip_edit or apply_button):
|
||||
bind_mode_option.focus_next = custom_ip_edit.get_path() if custom_ip_edit else apply_button.get_path()
|
||||
if custom_ip_edit and (port_override_enabled or apply_button):
|
||||
custom_ip_edit.focus_next = port_override_enabled.get_path() if port_override_enabled else apply_button.get_path()
|
||||
if port_override_enabled and (port_override_spin or apply_button):
|
||||
port_override_enabled.focus_next = port_override_spin.get_path() if port_override_spin else apply_button.get_path()
|
||||
if port_override_spin and (apply_button or bind_mode_option):
|
||||
port_override_spin.focus_next = apply_button.get_path() if apply_button else bind_mode_option.get_path()
|
||||
if apply_button and (bind_mode_option or apply_button):
|
||||
apply_button.focus_next = bind_mode_option.get_path() if bind_mode_option else apply_button.get_path()
|
||||
|
||||
_update_controls_enabled()
|
||||
set_status("Initializing...")
|
||||
|
||||
|
||||
func set_status(status: String) -> void:
|
||||
if status_label:
|
||||
status_label.text = status
|
||||
|
||||
if status_icon:
|
||||
if status.begins_with("Connected"):
|
||||
status_icon.color = Color.GREEN
|
||||
elif status.begins_with("Disconnected") or status.begins_with("Waiting"):
|
||||
status_icon.color = Color.ORANGE
|
||||
else:
|
||||
status_icon.color = Color.GRAY
|
||||
|
||||
|
||||
func set_bind_mode(mode: MCPEnums.BindMode) -> void:
|
||||
if not bind_mode_option:
|
||||
return
|
||||
_updating_ui = true
|
||||
match mode:
|
||||
MCPEnums.BindMode.WSL:
|
||||
bind_mode_option.select(1)
|
||||
MCPEnums.BindMode.CUSTOM:
|
||||
bind_mode_option.select(2)
|
||||
_:
|
||||
bind_mode_option.select(0)
|
||||
_updating_ui = false
|
||||
_update_controls_enabled()
|
||||
|
||||
|
||||
func get_bind_mode() -> MCPEnums.BindMode:
|
||||
if not bind_mode_option:
|
||||
return MCPEnums.BindMode.LOCALHOST
|
||||
match bind_mode_option.selected:
|
||||
1:
|
||||
return MCPEnums.BindMode.WSL
|
||||
2:
|
||||
return MCPEnums.BindMode.CUSTOM
|
||||
_:
|
||||
return MCPEnums.BindMode.LOCALHOST
|
||||
|
||||
|
||||
func set_custom_ip(ip: String) -> void:
|
||||
if not custom_ip_edit:
|
||||
return
|
||||
_updating_ui = true
|
||||
custom_ip_edit.text = ip
|
||||
_updating_ui = false
|
||||
|
||||
|
||||
func get_custom_ip() -> String:
|
||||
return custom_ip_edit.text if custom_ip_edit else ""
|
||||
|
||||
|
||||
func _on_bind_mode_selected(_idx: int) -> void:
|
||||
if _updating_ui:
|
||||
return
|
||||
_update_controls_enabled()
|
||||
|
||||
|
||||
func _on_port_override_toggled(_enabled: bool) -> void:
|
||||
if _updating_ui:
|
||||
return
|
||||
_update_controls_enabled()
|
||||
|
||||
|
||||
func _on_apply_pressed() -> void:
|
||||
config_applied.emit(get_config())
|
||||
|
||||
|
||||
func get_config() -> Dictionary:
|
||||
return {
|
||||
"bind_mode": get_bind_mode(),
|
||||
"custom_ip": get_custom_ip(),
|
||||
"port_override_enabled": port_override_enabled.button_pressed if port_override_enabled else false,
|
||||
"port_override": int(port_override_spin.value) if port_override_spin else 6550,
|
||||
}
|
||||
|
||||
|
||||
func set_config(bind_mode: MCPEnums.BindMode, custom_ip: String, port_enabled: bool, port_value: int) -> void:
|
||||
set_bind_mode(bind_mode)
|
||||
set_custom_ip(custom_ip)
|
||||
if port_override_enabled:
|
||||
_updating_ui = true
|
||||
port_override_enabled.button_pressed = port_enabled
|
||||
_updating_ui = false
|
||||
if port_override_spin:
|
||||
_updating_ui = true
|
||||
port_override_spin.value = clamp(port_value, 1, 65535)
|
||||
_updating_ui = false
|
||||
_update_controls_enabled()
|
||||
|
||||
|
||||
func _for_control_focus(c: Control) -> void:
|
||||
if not c:
|
||||
return
|
||||
c.focus_mode = Control.FOCUS_ALL
|
||||
|
||||
|
||||
func _unhandled_key_input(event: InputEvent) -> void:
|
||||
if not (event is InputEventKey):
|
||||
return
|
||||
var e := event as InputEventKey
|
||||
if not e.pressed or e.echo:
|
||||
return
|
||||
if e.keycode == KEY_ENTER or e.keycode == KEY_KP_ENTER:
|
||||
if apply_button:
|
||||
_on_apply_pressed()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
|
||||
func _update_controls_enabled() -> void:
|
||||
# Custom IP only editable in Custom mode
|
||||
var custom_ip_enabled := get_bind_mode() == MCPEnums.BindMode.CUSTOM
|
||||
if custom_ip_edit:
|
||||
custom_ip_edit.editable = custom_ip_enabled
|
||||
custom_ip_edit.modulate.a = 1.0 if custom_ip_enabled else 0.5
|
||||
if custom_ip_label:
|
||||
custom_ip_label.modulate.a = 1.0 if custom_ip_enabled else 0.5
|
||||
|
||||
# Port override controls
|
||||
var port_enabled := port_override_enabled and port_override_enabled.button_pressed
|
||||
if port_override_spin:
|
||||
port_override_spin.editable = port_enabled
|
||||
port_override_spin.modulate.a = 1.0 if port_enabled else 0.5
|
||||
if port_override_label:
|
||||
port_override_label.modulate.a = 1.0 if port_enabled else 0.5
|
||||
|
||||
|
||||
func set_addon_version(version: String) -> void:
|
||||
_addon_version = version
|
||||
_update_version_label()
|
||||
|
||||
|
||||
func set_server_version(version: String) -> void:
|
||||
if not version_label:
|
||||
return
|
||||
if version.is_empty():
|
||||
_update_version_label()
|
||||
elif _addon_version.is_empty():
|
||||
version_label.text = "Server: %s" % version
|
||||
elif version == _addon_version:
|
||||
version_label.text = "v%s" % version
|
||||
else:
|
||||
version_label.text = "Addon: %s | Server: %s" % [_addon_version, version]
|
||||
version_label.add_theme_color_override("font_color", Color.ORANGE)
|
||||
|
||||
|
||||
func clear_server_version() -> void:
|
||||
_update_version_label()
|
||||
if version_label:
|
||||
version_label.remove_theme_color_override("font_color")
|
||||
|
||||
|
||||
func _update_version_label() -> void:
|
||||
if not version_label:
|
||||
return
|
||||
if _addon_version.is_empty():
|
||||
version_label.text = ""
|
||||
else:
|
||||
version_label.text = "v%s" % _addon_version
|
||||
1
ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd.uid
Normal file
1
ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cwg8sy28ab8as
|
||||
101
ruf-der-pilze/addons/godot_mcp/ui/status_panel.tscn
Normal file
101
ruf-der-pilze/addons/godot_mcp/ui/status_panel.tscn
Normal file
@@ -0,0 +1,101 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://addons/godot_mcp/ui/status_panel.gd" id="1"]
|
||||
|
||||
[node name="StatusPanel" type="Control"]
|
||||
clip_contents = true
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="MarginContainer" type="MarginContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
theme_override_constants/margin_left = 12
|
||||
theme_override_constants/margin_top = 8
|
||||
theme_override_constants/margin_right = 12
|
||||
theme_override_constants/margin_bottom = 8
|
||||
|
||||
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 12
|
||||
|
||||
[node name="StatusRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/StatusRow"]
|
||||
layout_mode = 2
|
||||
text = "MCP Server:"
|
||||
|
||||
[node name="StatusIcon" type="ColorRect" parent="MarginContainer/VBoxContainer/StatusRow"]
|
||||
custom_minimum_size = Vector2(12, 12)
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 4
|
||||
color = Color(0.5, 0.5, 0.5, 1)
|
||||
|
||||
[node name="StatusLabel" type="Label" parent="MarginContainer/VBoxContainer/StatusRow"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
text = "Initializing..."
|
||||
|
||||
[node name="VersionLabel" type="Label" parent="MarginContainer/VBoxContainer/StatusRow"]
|
||||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.6, 0.6, 0.6, 1)
|
||||
text = ""
|
||||
|
||||
[node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="SettingsGrid" type="GridContainer" parent="MarginContainer/VBoxContainer"]
|
||||
layout_mode = 2
|
||||
columns = 2
|
||||
theme_override_constants/h_separation = 16
|
||||
theme_override_constants/v_separation = 8
|
||||
|
||||
[node name="BindLabel" type="Label" parent="MarginContainer/VBoxContainer/SettingsGrid"]
|
||||
layout_mode = 2
|
||||
text = "Bind mode"
|
||||
|
||||
[node name="BindModeOption" type="OptionButton" parent="MarginContainer/VBoxContainer/SettingsGrid"]
|
||||
custom_minimum_size = Vector2(120, 0)
|
||||
layout_mode = 2
|
||||
|
||||
[node name="CustomIpLabel" type="Label" parent="MarginContainer/VBoxContainer/SettingsGrid"]
|
||||
layout_mode = 2
|
||||
text = "Custom bind IP"
|
||||
|
||||
[node name="CustomIpEdit" type="LineEdit" parent="MarginContainer/VBoxContainer/SettingsGrid"]
|
||||
custom_minimum_size = Vector2(180, 0)
|
||||
layout_mode = 2
|
||||
placeholder_text = "e.g. 127.0.0.1"
|
||||
|
||||
[node name="PortOverrideLabel" type="Label" parent="MarginContainer/VBoxContainer/SettingsGrid"]
|
||||
layout_mode = 2
|
||||
text = "Port override"
|
||||
|
||||
[node name="PortOverrideControls" type="HBoxContainer" parent="MarginContainer/VBoxContainer/SettingsGrid"]
|
||||
layout_mode = 2
|
||||
theme_override_constants/separation = 8
|
||||
|
||||
[node name="PortOverrideEnabled" type="CheckBox" parent="MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls"]
|
||||
layout_mode = 2
|
||||
text = "Enable"
|
||||
|
||||
[node name="PortOverrideSpin" type="SpinBox" parent="MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls"]
|
||||
layout_mode = 2
|
||||
min_value = 1.0
|
||||
max_value = 65535.0
|
||||
step = 1.0
|
||||
value = 6550.0
|
||||
allow_greater = false
|
||||
allow_lesser = false
|
||||
rounded = true
|
||||
|
||||
[node name="Spacer" type="Control" parent="MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="ApplyButton" type="Button" parent="MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls"]
|
||||
layout_mode = 2
|
||||
text = "Apply"
|
||||
216
ruf-der-pilze/addons/godot_mcp/websocket_server.gd
Normal file
216
ruf-der-pilze/addons/godot_mcp/websocket_server.gd
Normal file
@@ -0,0 +1,216 @@
|
||||
@tool
|
||||
extends Node
|
||||
class_name MCPWebSocketServer
|
||||
|
||||
signal command_received(id: String, command: String, params: Dictionary)
|
||||
signal client_connected()
|
||||
signal client_disconnected()
|
||||
|
||||
const DEFAULT_PORT := 6550
|
||||
const STALE_CONNECTION_TIMEOUT_MSEC := 45000
|
||||
const CLOSE_CODE_STALE := 4002
|
||||
const CLOSE_REASON_STALE := "Connection timed out (no activity)"
|
||||
const CLOSE_CODE_REPLACED := 4003
|
||||
const CLOSE_REASON_REPLACED := "Replaced by new client"
|
||||
|
||||
var _server: TCPServer
|
||||
var _peer: StreamPeerTCP
|
||||
var _ws_peer: WebSocketPeer
|
||||
var _is_connected := false
|
||||
var _connected_host: String = ""
|
||||
var _connected_port: int = 0
|
||||
var _last_activity_msec: int = 0
|
||||
var _stale_reason: String = ""
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not _server:
|
||||
return
|
||||
|
||||
if _server.is_connection_available():
|
||||
_accept_connection()
|
||||
|
||||
if _ws_peer:
|
||||
_ws_peer.poll()
|
||||
_process_websocket()
|
||||
|
||||
|
||||
func start_server(port: int = DEFAULT_PORT, bind_address: String = "127.0.0.1") -> Error:
|
||||
_server = TCPServer.new()
|
||||
var err := _server.listen(port, bind_address)
|
||||
if err != OK:
|
||||
_server = null
|
||||
MCPLog.error("Failed to start server on %s:%d: %s" % [bind_address, port, error_string(err)])
|
||||
return err
|
||||
|
||||
return OK
|
||||
|
||||
|
||||
func stop_server() -> void:
|
||||
if _ws_peer:
|
||||
_ws_peer.close()
|
||||
_ws_peer = null
|
||||
|
||||
if _peer:
|
||||
_peer.disconnect_from_host()
|
||||
_peer = null
|
||||
|
||||
if _server:
|
||||
_server.stop()
|
||||
_server = null
|
||||
|
||||
_is_connected = false
|
||||
_connected_host = ""
|
||||
_connected_port = 0
|
||||
_last_activity_msec = 0
|
||||
|
||||
|
||||
func get_connected_host() -> String:
|
||||
return _connected_host
|
||||
|
||||
|
||||
func get_connected_port() -> int:
|
||||
return _connected_port
|
||||
|
||||
|
||||
func send_response(response: Dictionary) -> void:
|
||||
if not _ws_peer or _ws_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
|
||||
MCPLog.warn("Cannot send response: not connected")
|
||||
return
|
||||
|
||||
var json := JSON.stringify(response)
|
||||
_ws_peer.send_text(json)
|
||||
|
||||
|
||||
func _accept_connection() -> void:
|
||||
var incoming := _server.take_connection()
|
||||
if not incoming:
|
||||
return
|
||||
|
||||
if _ws_peer != null:
|
||||
if _is_stale_connection():
|
||||
MCPLog.warn("Replacing stale connection with new client (%s)" % _stale_reason)
|
||||
_force_close_connection()
|
||||
else:
|
||||
MCPLog.warn("Replacing active connection with new client (previous server likely exited without closing)")
|
||||
_force_close_connection(CLOSE_CODE_REPLACED, CLOSE_REASON_REPLACED)
|
||||
|
||||
_peer = incoming
|
||||
_ws_peer = WebSocketPeer.new()
|
||||
_ws_peer.outbound_buffer_size = 16 * 1024 * 1024 # 16MB for screenshot data
|
||||
var err := _ws_peer.accept_stream(_peer)
|
||||
if err != OK:
|
||||
MCPLog.error("Failed to accept WebSocket stream: %s" % error_string(err))
|
||||
_ws_peer = null
|
||||
_peer = null
|
||||
return
|
||||
|
||||
_connected_host = _peer.get_connected_host()
|
||||
_connected_port = _peer.get_connected_port()
|
||||
_last_activity_msec = Time.get_ticks_msec()
|
||||
|
||||
MCPLog.info("TCP connection received from %s:%d, awaiting WebSocket handshake..." % [_connected_host, _connected_port])
|
||||
|
||||
|
||||
func _process_websocket() -> void:
|
||||
if not _ws_peer:
|
||||
return
|
||||
|
||||
var state := _ws_peer.get_ready_state()
|
||||
|
||||
match state:
|
||||
WebSocketPeer.STATE_CONNECTING:
|
||||
pass
|
||||
|
||||
WebSocketPeer.STATE_OPEN:
|
||||
if not _is_connected:
|
||||
_is_connected = true
|
||||
_last_activity_msec = Time.get_ticks_msec()
|
||||
client_connected.emit()
|
||||
MCPLog.info("WebSocket handshake complete")
|
||||
|
||||
if _is_stale_connection():
|
||||
MCPLog.warn("Closing stale connection (%s)" % _stale_reason)
|
||||
_ws_peer.close(CLOSE_CODE_STALE, CLOSE_REASON_STALE)
|
||||
return
|
||||
|
||||
while _ws_peer.get_available_packet_count() > 0:
|
||||
_last_activity_msec = Time.get_ticks_msec()
|
||||
var packet := _ws_peer.get_packet()
|
||||
_handle_packet(packet)
|
||||
|
||||
WebSocketPeer.STATE_CLOSING:
|
||||
pass
|
||||
|
||||
WebSocketPeer.STATE_CLOSED:
|
||||
if _is_connected:
|
||||
_is_connected = false
|
||||
client_disconnected.emit()
|
||||
_ws_peer = null
|
||||
_peer = null
|
||||
|
||||
|
||||
func _force_close_connection(close_code: int = CLOSE_CODE_STALE, close_reason: String = CLOSE_REASON_STALE) -> void:
|
||||
if _ws_peer:
|
||||
_ws_peer.close(close_code, close_reason)
|
||||
_ws_peer = null
|
||||
if _peer:
|
||||
_peer.disconnect_from_host()
|
||||
_peer = null
|
||||
if _is_connected:
|
||||
_is_connected = false
|
||||
client_disconnected.emit()
|
||||
_last_activity_msec = 0
|
||||
_connected_host = ""
|
||||
_connected_port = 0
|
||||
|
||||
|
||||
func _is_stale_connection() -> bool:
|
||||
if _last_activity_msec == 0:
|
||||
return false
|
||||
if _peer and _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
|
||||
_stale_reason = "TCP peer disconnected"
|
||||
return true
|
||||
if Time.get_ticks_msec() - _last_activity_msec > STALE_CONNECTION_TIMEOUT_MSEC:
|
||||
_stale_reason = "no activity for %ds" % (STALE_CONNECTION_TIMEOUT_MSEC / 1000)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _handle_packet(packet: PackedByteArray) -> void:
|
||||
var text := packet.get_string_from_utf8()
|
||||
|
||||
var json := JSON.new()
|
||||
var err := json.parse(text)
|
||||
if err != OK:
|
||||
MCPLog.error("Failed to parse command: %s" % json.get_error_message())
|
||||
_send_error_response("", "PARSE_ERROR", "Invalid JSON: %s" % json.get_error_message())
|
||||
return
|
||||
|
||||
if not json.data is Dictionary:
|
||||
MCPLog.error("Invalid command format: expected JSON object")
|
||||
_send_error_response("", "INVALID_FORMAT", "Expected JSON object")
|
||||
return
|
||||
|
||||
var data: Dictionary = json.data
|
||||
if not data.has("id") or not data.has("command"):
|
||||
MCPLog.error("Invalid command format")
|
||||
_send_error_response(data.get("id", ""), "INVALID_FORMAT", "Missing 'id' or 'command' field")
|
||||
return
|
||||
|
||||
var id: String = str(data.get("id"))
|
||||
var command: String = data.get("command")
|
||||
var params: Dictionary = data.get("params", {})
|
||||
|
||||
command_received.emit(id, command, params)
|
||||
|
||||
|
||||
func _send_error_response(id: String, code: String, message: String) -> void:
|
||||
send_response({
|
||||
"id": id,
|
||||
"status": "error",
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message
|
||||
}
|
||||
})
|
||||
1
ruf-der-pilze/addons/godot_mcp/websocket_server.gd.uid
Normal file
1
ruf-der-pilze/addons/godot_mcp/websocket_server.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bkk3o37h86b85
|
||||
1
ruf-der-pilze/icon.svg
Normal file
1
ruf-der-pilze/icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>
|
||||
|
After Width: | Height: | Size: 995 B |
43
ruf-der-pilze/icon.svg.import
Normal file
43
ruf-der-pilze/icon.svg.import
Normal file
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://ddho0we3wmje5"
|
||||
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://icon.svg"
|
||||
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.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=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
38
ruf-der-pilze/project.godot
Normal file
38
ruf-der-pilze/project.godot
Normal file
@@ -0,0 +1,38 @@
|
||||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; since the parameters that go here are not all obvious.
|
||||
;
|
||||
; Format:
|
||||
; [section] ; section goes between []
|
||||
; param=value ; assign values to parameters
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Ruf der Pilze"
|
||||
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
[autoload]
|
||||
|
||||
MCPGameBridge="res://addons/godot_mcp/game_bridge/mcp_game_bridge.gd"
|
||||
|
||||
[editor_plugins]
|
||||
|
||||
enabled=PackedStringArray("res://addons/godot_mcp/plugin.cfg")
|
||||
|
||||
[godot_mcp]
|
||||
|
||||
bind_mode=0
|
||||
custom_bind_ip=""
|
||||
port_override_enabled=false
|
||||
port_override=6550
|
||||
|
||||
[physics]
|
||||
|
||||
3d/physics_engine="Jolt Physics"
|
||||
|
||||
[rendering]
|
||||
|
||||
rendering_device/driver.windows="d3d12"
|
||||
Reference in New Issue
Block a user