From c4d29486c4c49a74cc0ddb0b71fd607a3108ecf5 Mon Sep 17 00:00:00 2001 From: "s0wlz (Matthias Puchstein)" Date: Mon, 13 Apr 2026 04:34:39 +0200 Subject: [PATCH] chore: initial project state (godot-mcp addon, docs) --- docs/STATUS.md | 39 + docs/plans/.gitkeep | 0 ...6-04-13-multiplayer-grundgeruest-design.md | 184 +++++ ...026-04-13-multiplayer-grundgeruest-plan.md | 338 +++++++++ ruf-der-pilze/.editorconfig | 4 + ruf-der-pilze/.gitattributes | 2 + ruf-der-pilze/.gitignore | 3 + ruf-der-pilze/CLAUDE.md | 220 ++++++ .../addons/godot_mcp/command_router.gd | 40 ++ .../addons/godot_mcp/command_router.gd.uid | 1 + .../godot_mcp/commands/animation_commands.gd | 633 +++++++++++++++++ .../commands/animation_commands.gd.uid | 1 + .../godot_mcp/commands/debug_commands.gd | 134 ++++ .../godot_mcp/commands/debug_commands.gd.uid | 1 + .../godot_mcp/commands/input_commands.gd | 193 +++++ .../godot_mcp/commands/input_commands.gd.uid | 1 + .../godot_mcp/commands/node_commands.gd | 302 ++++++++ .../godot_mcp/commands/node_commands.gd.uid | 1 + .../godot_mcp/commands/profiler_commands.gd | 143 ++++ .../commands/profiler_commands.gd.uid | 1 + .../godot_mcp/commands/project_commands.gd | 114 +++ .../commands/project_commands.gd.uid | 1 + .../godot_mcp/commands/resource_commands.gd | 293 ++++++++ .../commands/resource_commands.gd.uid | 1 + .../godot_mcp/commands/scene3d_commands.gd | 162 +++++ .../commands/scene3d_commands.gd.uid | 1 + .../godot_mcp/commands/scene_commands.gd | 133 ++++ .../godot_mcp/commands/scene_commands.gd.uid | 1 + .../godot_mcp/commands/screenshot_commands.gd | 130 ++++ .../commands/screenshot_commands.gd.uid | 1 + .../godot_mcp/commands/script_commands.gd | 73 ++ .../godot_mcp/commands/script_commands.gd.uid | 1 + .../godot_mcp/commands/selection_commands.gd | 170 +++++ .../commands/selection_commands.gd.uid | 1 + .../godot_mcp/commands/system_commands.gd | 37 + .../godot_mcp/commands/system_commands.gd.uid | 1 + .../godot_mcp/commands/tilemap_commands.gd | 665 ++++++++++++++++++ .../commands/tilemap_commands.gd.uid | 1 + .../addons/godot_mcp/core/base_command.gd | 60 ++ .../addons/godot_mcp/core/base_command.gd.uid | 1 + .../addons/godot_mcp/core/mcp_constants.gd | 5 + .../godot_mcp/core/mcp_constants.gd.uid | 1 + .../godot_mcp/core/mcp_debugger_plugin.gd | 283 ++++++++ .../godot_mcp/core/mcp_debugger_plugin.gd.uid | 1 + .../addons/godot_mcp/core/mcp_enums.gd | 10 + .../addons/godot_mcp/core/mcp_enums.gd.uid | 1 + .../addons/godot_mcp/core/mcp_log.gd | 14 + .../addons/godot_mcp/core/mcp_log.gd.uid | 1 + .../addons/godot_mcp/core/mcp_logger.gd | 92 +++ .../addons/godot_mcp/core/mcp_logger.gd.uid | 1 + .../addons/godot_mcp/core/mcp_utils.gd | 129 ++++ .../addons/godot_mcp/core/mcp_utils.gd.uid | 1 + .../game_bridge/mcp_frame_profiler.gd | 61 ++ .../game_bridge/mcp_frame_profiler.gd.uid | 1 + .../godot_mcp/game_bridge/mcp_game_bridge.gd | 556 +++++++++++++++ .../game_bridge/mcp_game_bridge.gd.uid | 1 + ruf-der-pilze/addons/godot_mcp/plugin.cfg | 8 + ruf-der-pilze/addons/godot_mcp/plugin.gd | 303 ++++++++ ruf-der-pilze/addons/godot_mcp/plugin.gd.uid | 1 + .../addons/godot_mcp/ui/status_panel.gd | 226 ++++++ .../addons/godot_mcp/ui/status_panel.gd.uid | 1 + .../addons/godot_mcp/ui/status_panel.tscn | 101 +++ .../addons/godot_mcp/websocket_server.gd | 216 ++++++ .../addons/godot_mcp/websocket_server.gd.uid | 1 + ruf-der-pilze/icon.svg | 1 + ruf-der-pilze/icon.svg.import | 43 ++ ruf-der-pilze/project.godot | 38 + 67 files changed, 6185 insertions(+) create mode 100644 docs/STATUS.md create mode 100644 docs/plans/.gitkeep create mode 100644 docs/plans/2026-04-13-multiplayer-grundgeruest-design.md create mode 100644 docs/plans/2026-04-13-multiplayer-grundgeruest-plan.md create mode 100644 ruf-der-pilze/.editorconfig create mode 100644 ruf-der-pilze/.gitattributes create mode 100644 ruf-der-pilze/.gitignore create mode 100644 ruf-der-pilze/CLAUDE.md create mode 100644 ruf-der-pilze/addons/godot_mcp/command_router.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/command_router.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/animation_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/animation_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/debug_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/debug_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/input_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/input_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/node_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/node_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/profiler_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/profiler_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/project_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/project_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/resource_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/resource_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/scene3d_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/scene3d_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/scene_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/scene_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/screenshot_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/screenshot_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/script_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/script_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/selection_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/selection_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/system_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/system_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/tilemap_commands.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/commands/tilemap_commands.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/core/base_command.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/core/base_command.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_debugger_plugin.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_debugger_plugin.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_frame_profiler.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_frame_profiler.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_game_bridge.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_game_bridge.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/plugin.cfg create mode 100644 ruf-der-pilze/addons/godot_mcp/plugin.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/plugin.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd.uid create mode 100644 ruf-der-pilze/addons/godot_mcp/ui/status_panel.tscn create mode 100644 ruf-der-pilze/addons/godot_mcp/websocket_server.gd create mode 100644 ruf-der-pilze/addons/godot_mcp/websocket_server.gd.uid create mode 100644 ruf-der-pilze/icon.svg create mode 100644 ruf-der-pilze/icon.svg.import create mode 100644 ruf-der-pilze/project.godot diff --git a/docs/STATUS.md b/docs/STATUS.md new file mode 100644 index 0000000..767f7ce --- /dev/null +++ b/docs/STATUS.md @@ -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? diff --git a/docs/plans/.gitkeep b/docs/plans/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/plans/2026-04-13-multiplayer-grundgeruest-design.md b/docs/plans/2026-04-13-multiplayer-grundgeruest-design.md new file mode 100644 index 0000000..de318a8 --- /dev/null +++ b/docs/plans/2026-04-13-multiplayer-grundgeruest-design.md @@ -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). diff --git a/docs/plans/2026-04-13-multiplayer-grundgeruest-plan.md b/docs/plans/2026-04-13-multiplayer-grundgeruest-plan.md new file mode 100644 index 0000000..3c5b6f1 --- /dev/null +++ b/docs/plans/2026-04-13-multiplayer-grundgeruest-plan.md @@ -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: ` erscheint wenn Client verbindet +- [ ] `[Client] Verbunden. Meine peer_id: ` erscheint im Client +- [ ] `[Client] Willkommen, meine peer_id ist: ` erscheint im Client (RPC angekommen) +- [ ] `[Server] Peer getrennt: ` erscheint wenn Client schließt +- [ ] Kein `push_error` oder Parser-Fehler in keiner Ansicht diff --git a/ruf-der-pilze/.editorconfig b/ruf-der-pilze/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/ruf-der-pilze/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/ruf-der-pilze/.gitattributes b/ruf-der-pilze/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/ruf-der-pilze/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/ruf-der-pilze/.gitignore b/ruf-der-pilze/.gitignore new file mode 100644 index 0000000..0af181c --- /dev/null +++ b/ruf-der-pilze/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/ruf-der-pilze/CLAUDE.md b/ruf-der-pilze/CLAUDE.md new file mode 100644 index 0000000..52a0a60 --- /dev/null +++ b/ruf-der-pilze/CLAUDE.md @@ -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 diff --git a/ruf-der-pilze/addons/godot_mcp/command_router.gd b/ruf-der-pilze/addons/godot_mcp/command_router.gd new file mode 100644 index 0000000..93d5ec0 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/command_router.gd @@ -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 diff --git a/ruf-der-pilze/addons/godot_mcp/command_router.gd.uid b/ruf-der-pilze/addons/godot_mcp/command_router.gd.uid new file mode 100644 index 0000000..93e6c12 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/command_router.gd.uid @@ -0,0 +1 @@ +uid://dx5jdjvtk6qce diff --git a/ruf-der-pilze/addons/godot_mcp/commands/animation_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/animation_commands.gd new file mode 100644 index 0000000..d7fb6a9 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/animation_commands.gd @@ -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}) diff --git a/ruf-der-pilze/addons/godot_mcp/commands/animation_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/animation_commands.gd.uid new file mode 100644 index 0000000..6ce28de --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/animation_commands.gd.uid @@ -0,0 +1 @@ +uid://c3hb6slopq75j diff --git a/ruf-der-pilze/addons/godot_mcp/commands/debug_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/debug_commands.gd new file mode 100644 index 0000000..c1d530b --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/debug_commands.gd @@ -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, + }) diff --git a/ruf-der-pilze/addons/godot_mcp/commands/debug_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/debug_commands.gd.uid new file mode 100644 index 0000000..8a7c4ac --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/debug_commands.gd.uid @@ -0,0 +1 @@ +uid://bfm205ak170xo diff --git a/ruf-der-pilze/addons/godot_mcp/commands/input_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/input_commands.gd new file mode 100644 index 0000000..9ef56cc --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/input_commands.gd @@ -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 diff --git a/ruf-der-pilze/addons/godot_mcp/commands/input_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/input_commands.gd.uid new file mode 100644 index 0000000..fda304b --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/input_commands.gd.uid @@ -0,0 +1 @@ +uid://doirdhuupjsk diff --git a/ruf-der-pilze/addons/godot_mcp/commands/node_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/node_commands.gd new file mode 100644 index 0000000..8dd22d6 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/node_commands.gd @@ -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({}) + + diff --git a/ruf-der-pilze/addons/godot_mcp/commands/node_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/node_commands.gd.uid new file mode 100644 index 0000000..9a18ae1 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/node_commands.gd.uid @@ -0,0 +1 @@ +uid://hahpgho4qb0b diff --git a/ruf-der-pilze/addons/godot_mcp/commands/profiler_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/profiler_commands.gd new file mode 100644 index 0000000..2fc3cec --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/profiler_commands.gd @@ -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 diff --git a/ruf-der-pilze/addons/godot_mcp/commands/profiler_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/profiler_commands.gd.uid new file mode 100644 index 0000000..b52a687 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/profiler_commands.gd.uid @@ -0,0 +1 @@ +uid://d3b5hmxyxcbsg diff --git a/ruf-der-pilze/addons/godot_mcp/commands/project_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/project_commands.gd new file mode 100644 index 0000000..295d8a3 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/project_commands.gd @@ -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/" 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 + } diff --git a/ruf-der-pilze/addons/godot_mcp/commands/project_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/project_commands.gd.uid new file mode 100644 index 0000000..53edc7e --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/project_commands.gd.uid @@ -0,0 +1 @@ +uid://dn711qsn11x65 diff --git a/ruf-der-pilze/addons/godot_mcp/commands/resource_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/resource_commands.gd new file mode 100644 index 0000000..178b7a6 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/resource_commands.gd @@ -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) + } diff --git a/ruf-der-pilze/addons/godot_mcp/commands/resource_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/resource_commands.gd.uid new file mode 100644 index 0000000..d3eeff7 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/resource_commands.gd.uid @@ -0,0 +1 @@ +uid://lkiiwjdnyop4 diff --git a/ruf-der-pilze/addons/godot_mcp/commands/scene3d_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/scene3d_commands.gd new file mode 100644 index 0000000..ec07a33 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/scene3d_commands.gd @@ -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) diff --git a/ruf-der-pilze/addons/godot_mcp/commands/scene3d_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/scene3d_commands.gd.uid new file mode 100644 index 0000000..47fc910 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/scene3d_commands.gd.uid @@ -0,0 +1 @@ +uid://cfe6u1whveo5g diff --git a/ruf-der-pilze/addons/godot_mcp/commands/scene_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/scene_commands.gd new file mode 100644 index 0000000..6b2fb5a --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/scene_commands.gd @@ -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}) + diff --git a/ruf-der-pilze/addons/godot_mcp/commands/scene_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/scene_commands.gd.uid new file mode 100644 index 0000000..978ca95 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/scene_commands.gd.uid @@ -0,0 +1 @@ +uid://bv3do28j0hvxy diff --git a/ruf-der-pilze/addons/godot_mcp/commands/screenshot_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/screenshot_commands.gd new file mode 100644 index 0000000..0a03b0e --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/screenshot_commands.gd @@ -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 diff --git a/ruf-der-pilze/addons/godot_mcp/commands/screenshot_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/screenshot_commands.gd.uid new file mode 100644 index 0000000..6faeba5 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/screenshot_commands.gd.uid @@ -0,0 +1 @@ +uid://dhvxaokhfr0w5 diff --git a/ruf-der-pilze/addons/godot_mcp/commands/script_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/script_commands.gd new file mode 100644 index 0000000..e8f4aea --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/script_commands.gd @@ -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({}) diff --git a/ruf-der-pilze/addons/godot_mcp/commands/script_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/script_commands.gd.uid new file mode 100644 index 0000000..f021f89 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/script_commands.gd.uid @@ -0,0 +1 @@ +uid://bka3ycfpg2ug diff --git a/ruf-der-pilze/addons/godot_mcp/commands/selection_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/selection_commands.gd new file mode 100644 index 0000000..59f52ee --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/selection_commands.gd @@ -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({}) diff --git a/ruf-der-pilze/addons/godot_mcp/commands/selection_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/selection_commands.gd.uid new file mode 100644 index 0000000..347f39a --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/selection_commands.gd.uid @@ -0,0 +1 @@ +uid://mv0eiufscf2n diff --git a/ruf-der-pilze/addons/godot_mcp/commands/system_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/system_commands.gd new file mode 100644 index 0000000..1e2259c --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/system_commands.gd @@ -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" diff --git a/ruf-der-pilze/addons/godot_mcp/commands/system_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/system_commands.gd.uid new file mode 100644 index 0000000..e7f5629 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/system_commands.gd.uid @@ -0,0 +1 @@ +uid://c22fvs7y7mjiu diff --git a/ruf-der-pilze/addons/godot_mcp/commands/tilemap_commands.gd b/ruf-der-pilze/addons/godot_mcp/commands/tilemap_commands.gd new file mode 100644 index 0000000..408bb82 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/tilemap_commands.gd @@ -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}) diff --git a/ruf-der-pilze/addons/godot_mcp/commands/tilemap_commands.gd.uid b/ruf-der-pilze/addons/godot_mcp/commands/tilemap_commands.gd.uid new file mode 100644 index 0000000..eb0a1e4 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/commands/tilemap_commands.gd.uid @@ -0,0 +1 @@ +uid://cuducprbjgili diff --git a/ruf-der-pilze/addons/godot_mcp/core/base_command.gd b/ruf-der-pilze/addons/godot_mcp/core/base_command.gd new file mode 100644 index 0000000..7b41556 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/base_command.gd @@ -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) diff --git a/ruf-der-pilze/addons/godot_mcp/core/base_command.gd.uid b/ruf-der-pilze/addons/godot_mcp/core/base_command.gd.uid new file mode 100644 index 0000000..bf5e64b --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/base_command.gd.uid @@ -0,0 +1 @@ +uid://btrhwuu4jmt7 diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd b/ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd new file mode 100644 index 0000000..2f98769 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd @@ -0,0 +1,5 @@ +class_name MCPConstants + +const LOCALHOST_BIND_ADDRESS = "127.0.0.1" +const PORT_MIN = 1 +const PORT_MAX = 65535 diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd.uid b/ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd.uid new file mode 100644 index 0000000..53ff172 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_constants.gd.uid @@ -0,0 +1 @@ +uid://daod4t3pjl3ce diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_debugger_plugin.gd b/ruf-der-pilze/addons/godot_mcp/core/mcp_debugger_plugin.gd new file mode 100644 index 0000000..c52a5c8 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_debugger_plugin.gd @@ -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) diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_debugger_plugin.gd.uid b/ruf-der-pilze/addons/godot_mcp/core/mcp_debugger_plugin.gd.uid new file mode 100644 index 0000000..b65347c --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_debugger_plugin.gd.uid @@ -0,0 +1 @@ +uid://cakxaiej4lb6w diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd b/ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd new file mode 100644 index 0000000..1e759dc --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd @@ -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" \ No newline at end of file diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd.uid b/ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd.uid new file mode 100644 index 0000000..586ee9a --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_enums.gd.uid @@ -0,0 +1 @@ +uid://cdrfh4c15dks diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd b/ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd new file mode 100644 index 0000000..11503c0 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd @@ -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) diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd.uid b/ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd.uid new file mode 100644 index 0000000..a58b950 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_log.gd.uid @@ -0,0 +1 @@ +uid://bavtmnjx74t1l diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd b/ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd new file mode 100644 index 0000000..df2442c --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd @@ -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() diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd.uid b/ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd.uid new file mode 100644 index 0000000..178a852 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_logger.gd.uid @@ -0,0 +1 @@ +uid://dayisgpy78rci diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd b/ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd new file mode 100644 index 0000000..e1789ad --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd @@ -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) diff --git a/ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd.uid b/ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd.uid new file mode 100644 index 0000000..5a51590 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/core/mcp_utils.gd.uid @@ -0,0 +1 @@ +uid://c63cmeafr4oqh diff --git a/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_frame_profiler.gd b/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_frame_profiler.gd new file mode 100644 index 0000000..7c70128 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_frame_profiler.gd @@ -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)), + } diff --git a/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_frame_profiler.gd.uid b/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_frame_profiler.gd.uid new file mode 100644 index 0000000..042e459 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_frame_profiler.gd.uid @@ -0,0 +1 @@ +uid://cbefgyjtpbhqq diff --git a/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_game_bridge.gd b/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_game_bridge.gd new file mode 100644 index 0000000..b432042 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_game_bridge.gd @@ -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, + }]) diff --git a/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_game_bridge.gd.uid b/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_game_bridge.gd.uid new file mode 100644 index 0000000..83de433 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/game_bridge/mcp_game_bridge.gd.uid @@ -0,0 +1 @@ +uid://c606pf78f127d diff --git a/ruf-der-pilze/addons/godot_mcp/plugin.cfg b/ruf-der-pilze/addons/godot_mcp/plugin.cfg new file mode 100644 index 0000000..30e0594 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/plugin.cfg @@ -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" diff --git a/ruf-der-pilze/addons/godot_mcp/plugin.gd b/ruf-der-pilze/addons/godot_mcp/plugin.gd new file mode 100644 index 0000000..2d108c7 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/plugin.gd @@ -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) diff --git a/ruf-der-pilze/addons/godot_mcp/plugin.gd.uid b/ruf-der-pilze/addons/godot_mcp/plugin.gd.uid new file mode 100644 index 0000000..baa088a --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/plugin.gd.uid @@ -0,0 +1 @@ +uid://bhl2ru4tj4sa8 diff --git a/ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd b/ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd new file mode 100644 index 0000000..c06d461 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd @@ -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 diff --git a/ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd.uid b/ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd.uid new file mode 100644 index 0000000..6035253 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/ui/status_panel.gd.uid @@ -0,0 +1 @@ +uid://cwg8sy28ab8as diff --git a/ruf-der-pilze/addons/godot_mcp/ui/status_panel.tscn b/ruf-der-pilze/addons/godot_mcp/ui/status_panel.tscn new file mode 100644 index 0000000..719a003 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/ui/status_panel.tscn @@ -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" diff --git a/ruf-der-pilze/addons/godot_mcp/websocket_server.gd b/ruf-der-pilze/addons/godot_mcp/websocket_server.gd new file mode 100644 index 0000000..cf3e30a --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/websocket_server.gd @@ -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 + } + }) diff --git a/ruf-der-pilze/addons/godot_mcp/websocket_server.gd.uid b/ruf-der-pilze/addons/godot_mcp/websocket_server.gd.uid new file mode 100644 index 0000000..b244534 --- /dev/null +++ b/ruf-der-pilze/addons/godot_mcp/websocket_server.gd.uid @@ -0,0 +1 @@ +uid://bkk3o37h86b85 diff --git a/ruf-der-pilze/icon.svg b/ruf-der-pilze/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/ruf-der-pilze/icon.svg @@ -0,0 +1 @@ + diff --git a/ruf-der-pilze/icon.svg.import b/ruf-der-pilze/icon.svg.import new file mode 100644 index 0000000..bad7e11 --- /dev/null +++ b/ruf-der-pilze/icon.svg.import @@ -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 diff --git a/ruf-der-pilze/project.godot b/ruf-der-pilze/project.godot new file mode 100644 index 0000000..c825502 --- /dev/null +++ b/ruf-der-pilze/project.godot @@ -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"