From ec4fc8e756e2c3ab2bf9f165b25c2537dd0fd0aa Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 13:35:31 -0700 Subject: [PATCH] changes for stuff --- .opencode/package-lock.json | 24 +- .opencode/plans/kq4-room-navigator.md | 164 + .opencode/skills/kq4-room-navigator/SKILL.md | 170 + SetPiece_.gd | 38 + inventory/items/splash_item.tres | 12 +- mcp_interaction_server.gd | 4421 +++++++++++++++++ mcp_interaction_server.gd.uid | 1 + .../kq4_099_transitional_room.gd | 10 - .../kq4_099_transitional_room.gd.uid | 1 - .../kq4_099_transitional_room.tscn | 30 - .../kq4_099_transitional_room.tscn.uid | 1 - .../kq4_placeholder_template.gd.uid | 1 - .../pic_99_visual.png.import | 40 - scripts/build_room_graph.py | 367 ++ tests/test_room_navigation.py | 516 ++ tools/kq4_room_navigator.py | 90 + 16 files changed, 5785 insertions(+), 101 deletions(-) create mode 100644 .opencode/plans/kq4-room-navigator.md create mode 100644 .opencode/skills/kq4-room-navigator/SKILL.md create mode 100644 mcp_interaction_server.gd create mode 100644 mcp_interaction_server.gd.uid delete mode 100644 scenes/kq4_099_transitional_room/kq4_099_transitional_room.gd delete mode 100644 scenes/kq4_099_transitional_room/kq4_099_transitional_room.gd.uid delete mode 100644 scenes/kq4_099_transitional_room/kq4_099_transitional_room.tscn delete mode 100644 scenes/kq4_099_transitional_room/kq4_099_transitional_room.tscn.uid delete mode 100644 scenes/kq4_099_transitional_room/kq4_placeholder_template.gd.uid delete mode 100644 scenes/kq4_099_transitional_room/pic_99_visual.png.import create mode 100644 scripts/build_room_graph.py create mode 100644 tests/test_room_navigation.py create mode 100644 tools/kq4_room_navigator.py diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index 044aadb..83e3719 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@opencode-ai/plugin": "1.14.27" + "@opencode-ai/plugin": "1.14.29" } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { @@ -87,13 +87,13 @@ ] }, "node_modules/@opencode-ai/plugin": { - "version": "1.14.27", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.27.tgz", - "integrity": "sha512-8J8JkrInF5oWaR2rsLuwQktdF9Yq3xoyA8B42/B8Te74/q4rqOt7YzWK2I/ZSxvKA/Ct+iQ8f2OeUrpQ2INgSw==", + "version": "1.14.29", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.29.tgz", + "integrity": "sha512-GwmBg7dajawma/6tzpoK/JMbcRcUTg27XHlnxZOFWG85WDz4M67hxFYnvE+BnJj9k7tTLXTfSR+pgfcdqbUDAg==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.14.27", - "effect": "4.0.0-beta.48", + "@opencode-ai/sdk": "1.14.29", + "effect": "4.0.0-beta.57", "zod": "4.1.8" }, "peerDependencies": { @@ -110,9 +110,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.14.27", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.27.tgz", - "integrity": "sha512-mpzDFDAGi+8wKEcm4aP0HVtS56rN/q2hVs8Ai6JziPu7NuTMddfFoEvddArYsgkRWUfHL5ypZc1mDmAMEiO1vg==", + "version": "1.14.29", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.29.tgz", + "integrity": "sha512-y6wNTlHhgfwLdp01EwdnMFVxUS1FLgz7MZh7H3+jROG2v02GqGDy/gPH3ME0kI+sqQ4qSlk/9AJ+YbKAruPaZw==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -149,9 +149,9 @@ } }, "node_modules/effect": { - "version": "4.0.0-beta.48", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", - "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", + "version": "4.0.0-beta.57", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.57.tgz", + "integrity": "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", diff --git a/.opencode/plans/kq4-room-navigator.md b/.opencode/plans/kq4-room-navigator.md new file mode 100644 index 0000000..dfe4a78 --- /dev/null +++ b/.opencode/plans/kq4-room-navigator.md @@ -0,0 +1,164 @@ +--- +model: "auto" +kill: ["alpha-mask-creator"] +date_introduced: 2026-04-28 +--- + +# Plan: kq4-room-navigator Skill (AI Agent Room Pathfinding via Godot MCP) + +## Context + +The game has ~96 rooms connected by TransitionPiece nodes. Each room's `.tscn` file defines its exits: node name (destination room), target UID, appear_at_node, label, and polygon coordinates. Room scripts connect the `interacted` signal to handlers that call `default_script()` on the transition piece, triggering a 3-step animated sequence (walk to exit → fade/swap scenes → walk to entrance in new room). + +The custom MCP server (`scripts/mcp_interaction_server.gd`) runs as an autoload at TCP port 9090 with ~80 commands including `eval` (arbitrary GDScript execution), `click`, `screenshot`, `get_scene_tree`, `get_property`, and `wait`. No Python client exists yet. + +**Key constraint**: Full runtime discovery via `eval` is fragile — instantiating .tscn files for graph discovery triggers script execution (`_ready()`) which references autoload singletons (ActionState, GameScript), causing errors when rooms aren't fully set up. **Solution**: Use targeted `find_nodes_by_class` MCP command for the currently-loaded room, and file parsing for global graph construction (leveraging existing `scripts/check_transitions.py`). + +## Approach + +Build a Python tool (`tools/kq4_room_navigator.py`) that: +1. Parses `.tscn` files to build complete room adjacency graph (reuse logic from `check_transitions.py`) +2. Runs BFS pathfinding between start and destination rooms +3. Connects to the MCP server on port 9090 for live navigation +4. Uses `eval` to compute click coordinates within TransitionPiece polygons in the running game +5. Executes step-by-step clicks with screenshot verification after each transition + +The SKILL.md guides an AI agent through: starting Godot, invoking the tool, and optionally verifying steps manually with MCP commands. + +## Implementation Plan + +### 1. Extend room graph parser (`scripts/build_room_graph.py`) + +**Purpose**: Parse all `.tscn` files to produce a queryable room adjacency graph with BFS pathfinding. Extends/extracts logic from existing `scripts/check_transitions.py`. + +**What it does**: +1. Scan `scenes/kq4_*/kq4_*.tscn` (exclude placeholder_template) +2. Parse each file to extract: + - Scene UID (from header `[gd_scene format=3 uid="uid://XXX"]`) + - Room name/directory stem (e.g., `kq4_004_ogres_cottage`) + - All TransitionPiece nodes → build adjacency list +3. Build bidirectional graph: `{room_name: [(exit_node_name, target_uid, appear_at_node, label, polygon), ...]}` + +**Output**: Returns a structure that supports BFS queries between any two rooms. + +**Interface** (callable from Python or CLI): +```python +def build_graph(scenes_dir: Path) -> dict[str, list[TransitionInfo]]: + """Parse all room .tscn files and return adjacency graph.""" + +def find_path(graph: dict, start_room: str, end_room: str) -> list[NavigationStep]: + """BFS from start to end. Returns ordered list of steps or None.""" + +@dataclass +class NavigationStep: + from_room: str # Current room name (e.g., "kq4_004_ogres_cottage") + exit_node_name: str # TransitionPiece node name (e.g., "kq4_010_forest_path") + to_room: str # Destination room name + label: str # Human-readable label (e.g., "Forest Path") + polygon: list[tuple] # Polygon vertices for click coordinate computation +``` + +**File**: `scripts/build_room_graph.py` + +### 3. Create the main navigator tool (`tools/kq4_room_navigator.py`) + +**Purpose**: End-to-end CLI tool that connects to MCP, builds graph, finds path, and navigates. + +**Workflow**: + +```bash +python tools/kq4_room_navigator.py --from kq4_004_ogres_cottage --to kq4_092_lolottes_throne_room +``` + +**Implementation flow**: + +1. **Build graph** (offline): Calls `build_graph()` to parse all rooms +2. **BFS pathfinding**: Finds shortest path from start → destination +3. **No path found**: Reports unreachable, lists connected components +4. **Path found**: Prints the step-by-step plan with click coordinates + +5. **Connect to MCP** (runtime): Opens TCP connection to port 9090 +6. **For each navigation step**: + a. `find_nodes_by_class(class_name="TransitionPiece")` — discover all transition pieces in current scene + b. Match the exit node name from our path → get runtime position + polygon + c. Compute click coordinate: centroid of the TransitionPiece's `polygon`, transformed to viewport coordinates + d. `click(x, y)` — trigger the transition + e. Wait for transition animation (2-3s via `wait` command or poll-loop) + f. **Verify**: `eval("return get_tree().root.get_node_or_null('Node2D/SceneViewport/background').name")` to confirm we've entered the expected room + g. If verification fails, retry up to 2 times with adjusted coordinates + h. Optionally take a screenshot via `screenshot()` for visual confirmation + +7. **Complete**: Print summary of navigation path taken + +**GDScript eval code used at runtime**: + +Find clickable position within TransitionPiece polygon: +```gdscript +# Returns {node_name, centroid_x, centroid_y} for matching transition piece +var bg = get_tree().root.get_node_or_null("Node2D/SceneViewport/background") +if not bg: return null +for child in bg.get_children(): + if child.has_method("is_class") and child.is_class("TransitionPiece"): + var p = child.position + child.polygon.reduce(func(p, a): return p + a, Vector2(0,0)) / child.polygon.size() + return {"node": child.name, "x": p.x, "y": p.y, "polygon_size": child.polygon.size()} +``` + +Verify current room: +```gdscript +var bg = get_tree().root.get_node_or_null("Node2D/SceneViewport/background") +return bg ? bg.name : null +``` + +**File**: `tools/kq4_room_navigator.py` + +### 4. Write SKILL.md (`.opencode/skills/kq4-room-navigator/SKILL.md`) + +The skill guide documents: + +- **When to use**: Planning navigation between rooms, verifying room connectivity, debugging transitions +- **Pre-requisites**: Godot game running with MCP server active on port 9090 +- **Quick start**: `python tools/kq4_room_navigator.py --from kq4_XXX --to kq4_YYY` +- **Manual MCP workflow** (for step-by-step agent control): + - Start Godot: `godot --path .` or run exported binary + - Verify connectivity: `{"command": "get_scene_tree"}` returns the current scene tree + - Discover exits in current room: `{"command": "find_nodes_by_class", "params": {"class_name": "TransitionPiece"}}` + - Click a transition: compute centroid from polygon data, then `{"command": "click", "params": {"x": px, "y": py}}` + - Wait and verify room change via `eval` or `screenshot` +- **Coordinate computation math**: How to convert TransitionPiece polygon (local-space) to viewport click coordinates: `viewport_x = transition_node.position.x + polygon_centroid.x`, accounting for any node scale transforms +- **Troubleshooting**: Common failures (server busy, node not found mid-transition, wrong room), escape hatches + +**File**: `.opencode/skills/kq4-room-navigator/SKILL.md` + +## Task Dependency Graph + +``` +[1. build_room_graph.py] ────────┐ + ├── [2. kq4_room_navigator.py] ─── [3. SKILL.md] +``` + +Tasks run sequentially. Task 3 documents the completed system. + +## Verification + +- Run `python scripts/build_room_graph.py` → should produce a graph with all rooms and their exits +- Test BFS: path from kq4_001_beach to an adjacent room should be 1 step; path to a far room tests multi-hop correctly +- Connect to running Godot instance via `tools/kq4_room_navigator.py` → verify it can navigate at least one transition end-to-end (from current room to an adjacent room) +- Verify failure handling: requesting a path between disconnected rooms returns "no path found" +- Test `_busy` retry logic by sending rapid commands + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Room not fully set up — `TransitionPiece._ready()` crashes on eval-instantiation | Can't discover transitions from unloaded rooms via MCP | File parsing for graph discovery is the primary approach; MCP runtime only queries currently-loaded scene | +| Transition animation timing varies | Click at wrong time → missed transition | Poll current room name via eval every 0.5s until it changes, with 10s timeout | +| Polygon coordinates are local to node — need transform to viewport | Click lands outside polygon | Account for node `position` AND `scale` when computing centroid; use `get_node_info` runtime to get actual global polygon positions | +| MCP server `_busy` state blocks commands | Navigation stalls | Retry with exponential backoff (0.1s, 0.2s, 0.4s, ...) and 3-second max wait | + +## Out of Scope + +- Path optimization beyond BFS shortest path +- Inventory/item-based transitions (e.g., needing a key to enter a room) +- Cutscene-triggered room changes (non-interaction transitions) +- Multiplayer considerations +- Auto-starting Godot from the tool (user starts game manually) diff --git a/.opencode/skills/kq4-room-navigator/SKILL.md b/.opencode/skills/kq4-room-navigator/SKILL.md new file mode 100644 index 0000000..c14e86c --- /dev/null +++ b/.opencode/skills/kq4-room-navigator/SKILL.md @@ -0,0 +1,170 @@ +--- + +## name: kq4-room-navigator description: Navigate between KQ4 rooms using the Godot MCP server. BFS pathfinds through room transition graph, then executes step-by-step clicks via McpInteractionServer (port 9090). Use when planning navigation between any two rooms or simulating player movement through the game world at runtime. Trigger phrases: "navigate from", "go to room", "path from X to Y", "walk to", "how do I get to room", "room navigation". + +# KQ4 Room Navigator + +## Overview + +This skill finds shortest paths between rooms and can execute them through the running game using Godot's MCP server. It combines offline `.tscn` file parsing for graph construction with runtime interaction via McpInteractionServer (TCP port 9090). + +The room transition system uses TransitionPiece nodes: each has an `exit_node_name` (destination), a clickable polygon, and connects rooms bidirectionally. Clicking a TransitionPiece triggers a 3-step animation (walk → fade/swap scenes → walk to entrance in new room). + +## When to Use + +- Planning multi-room navigation paths +- Verifying room connectivity +- Debugging why a path can't be traversed +- Automating room transitions during testing +- Generating step-by-step click instructions for agents + +## How It Works + +``` +.scenes/*.tscn files → adjacency graph (96 rooms, 233 exits) → BFS pathfinding → use MCP for execution +``` + +Room identification at runtime: + +- Game node tree: `/root/Node2D/SceneViewport/background` (always named "background") +- Room identity comes from `background.get_script().resource_path` (e.g., `res://scenes/kq4_010_forest_path/kq4_010_forest_path.gd`) +- TransitionPiece children have `.target` UIDs and `.label` strings for cross-referencing + +## Quick Start + +### Step 1 Make a plan: + +```bash +python tools/kq4_room_navigator.py --from kq4_003_fountain_pool --to kq4_011_enchanted_grove +``` + +Output: + +``` +Path: kq4_003_fountain_pool → kq4_011_enchanted_grove (3 steps) + 1. Click 'kq4_004_ogres_cottage' in kq4_003_fountain_pool → kq4_004_ogres_cottage [Ogre's Cottage] (click at: 1874, 714) + 2. Click 'kq4_010_forest_path' in kq4_004_ogres_cottage → kq4_010_forest_path [Forest Path] (click at: 1042, 1078) + 3. Click 'kq4_011_enchanted_grove' in kq4_010_forest_path → kq4_011_enchanted_grove [Enchanted Grove] (click at: 1898, 610) +``` + +This will tell you which set pieces to click on to get there. Specifically, this is suggesting that you use the walk interaction with kq4_004_ogres_cottage. This can be done using the mcp server for godot, and using the eval tool. + +Here's how to proceed. + +1. start the game (godot --path . &) + + ```bash + # Wait for "McpInteractionServer: Listening on 127.0.0.1:9090" in console + ``` + +2. Verify current room matches the starting point + +3. For each step, find TransitionPiece coordinates at runtime via GDScript eval + +4. Use the `func mock_interact(action = 0) -> void` on setpiece using the gdscript eval + +5. Poll until room changes to expected destination + +6. Verify final arrival + +## More detailed + +When implementing navigation step by step through MCP commands: + +### Starting the game: + +```bash +godot --path . & +# Wait for "McpInteractionServer: Listening on 127.0.0.1:9090" in console +``` + +### Then verify the connection: + +Send `{"command": "get_scene_tree"}` via TCP to verify MCP responds. The response will show the full node hierarchy including `Node2D/SceneViewport/background`. + +### Discover current room's exits + +```json +{"command": "find_nodes_by_class", "params": {"class_name": "TransitionPiece"}} +``` + +Returns all TransitionPieces in the active scene with `.name`, `.label`, `.target` properties. + +For rooms without scripts, identify via transition piece labels or target UIDs cross-referenced with `scripts/build_room_graph.py --room ` output. + +### Simulate interactions + +Use eval to find the centroid of a TransitionPiece's polygon in viewport space: + +```json +{ + "command": "eval", + "params": { + "code": " get_tree().root.get_node_or_null('Node2D/SceneViewport/background/kq4_010_forest_path').mock_interaction(0)" + } +} +``` + +Interactions match ActionState.Action (LOOK/WALK/ITEM/TALK) + +### Waiting after interaction + +```json +{"command": "wait", "params": {"frames": 60}} +``` + +Transition animation takes \~1-2 seconds (fade-out + scene swap + fade-in). Wait 30+ frames before checking room change. + +## Room Graph Structure + +The graph currently has: + +- **96 rooms** across 23 connected components +- Largest component: 36 rooms (starting from room 3 Fountain Pool) +- Second largest: 17 rooms (room 1 Beach area) +- Some rooms are fully disconnected (not all transitions wired bidirectionally yet) + +Use `python scripts/build_room_graph.py --room ` to check a room's available exits. + +## Common Issues + +| Problem | Diagnosis | Fix | +| --- | --- | --- | +| "No path" between expected connected rooms | Room has no matching .uid file or transition pieces not wired bidirectionally | Check `build_room_graph.py` output for which component each room belongs to; verify the target room's `.tscn` has correct UID and exit nodes | +| MCP "Server busy" error during navigation | Previous command hasn't completed (30s timeout on server) | Tool handles automatic retry with exponential backoff. If persistent, restart game. | +| Click lands outside polygon → transition doesn't trigger | Scale transform not applied to coordinates | Use runtime eval (not offline centroid). The `--navigate` flag uses MCP eval which reads actual node transforms. | +| "Room not found in graph" | Room name mismatch — must use exact `.tscn` filename stem | Run `python scripts/build_room_graph.py` to see available room names | +| "Expected X but game reports Y" during --navigate | Game is on different room than specified | Restart at correct room, or adjust --from flag. Check current room name via MCP eval. | + +## API Reference + +### build_room_graph.py + +```python +from build_room_graph import build_graph, find_path, NavigationStep + +graph = build_graph(scenes_dir) # RoomInfo dict keyed by room name +steps = find_path(graph, "kq4_003_fountain_pool", "kq4_011_enchanted_grove") # List[NavigationStep] or None +step.from_room # Navigation source room name +step.exit_node_name # TransitionPiece node to click +step.to_room # Destination room name +step.label # Human-readable label +step.viewport_centroid() # (x, y) tuple — polygon center in viewport coords +``` + +### kq4_room_navigator.py CLI + +```bash +python tools/kq4_room_navigator.py [--from ROOM] [--to ROOM] [summary] +``` + +## Key Files + +| File | Purpose | +| --- | --- | +| `tools/kq4_room_navigator.py` | CLI combining graph + BFS + MCP navigation | +| `scripts/build_room_graph.py` | Room adjacency graph builder + BFS pathfinding | +| `scripts/check_transitions.py` | Existing transition validation (related) | +| `TransitionPiece.gd` | TransitionPiece node class (class_name TransitionPiece) | +| `SetPiece_.gd` | Base interactive polygon class (class_name SetPiece) | +| | | diff --git a/SetPiece_.gd b/SetPiece_.gd index f0fee64..46c2e20 100644 --- a/SetPiece_.gd +++ b/SetPiece_.gd @@ -41,6 +41,44 @@ signal exited(lab) points_resource = value _update_polygon_from_resource() +func sample_polygon_point() -> Vector2: + if polygon.is_empty(): + return position + var sum_x = 0.0 + var sum_y = 0.0 + for p in polygon: + sum_x += p.x + sum_y += p.y + return to_global(Vector2(sum_x / polygon.size(), sum_y / polygon.size())) + +func get_screen_click_point() -> Vector2i: + var world_pt = sample_polygon_point() + var vp = get_viewport() + var screen_pt = vp.get_canvas_transform() * world_pt + return Vector2i(int(screen_pt.x), int(screen_pt.y)) + +func mock_interact(action = 0) -> void: + action = int(action) + var script_builder = get_node_or_null("/root/Node2D/GameScript") + if script_builder and script_builder.current_script and not script_builder.current_script.can_interrupt: + return + if interacted.get_connections().size() > 0: + emit_signal("interacted") + return + match action: + ActionState.Action.WALK: + if walked.get_connections().size() > 0: + emit_signal("walked") + ActionState.Action.LOOK: + if looked.get_connections().size() > 0: + emit_signal("looked") + ActionState.Action.TOUCH: + if touched.get_connections().size() > 0: + emit_signal("touched") + ActionState.Action.TALK: + if talked.get_connections().size() > 0: + emit_signal("talked") + func _input(event): if not Engine.is_editor_hint(): if event is InputEventMouseButton and Input.is_action_just_released("interact"): diff --git a/inventory/items/splash_item.tres b/inventory/items/splash_item.tres index c0d0b63..8bed057 100644 --- a/inventory/items/splash_item.tres +++ b/inventory/items/splash_item.tres @@ -1,6 +1,6 @@ -{ - "item_id": "splash", - "name": "Splash", - "icon": "res://splash.png", - "combination_category": "potion" -} +[resource _type="ItemDefinition" uid="uid://dovkj7b9xqwpp"] + +id = "splash" +name = "Splash" +combination_category = "potion" +icon = null diff --git a/mcp_interaction_server.gd b/mcp_interaction_server.gd new file mode 100644 index 0000000..6ea8256 --- /dev/null +++ b/mcp_interaction_server.gd @@ -0,0 +1,4421 @@ +extends Node + +# MCP Interaction Server - TCP server for game interaction +# Runs as an autoload inside the Godot game, accepting JSON commands over TCP. +# No class_name to avoid autoload conflict. + +var _server: TCPServer +var _client: StreamPeerTCP +var _buffer: String = "" +var _busy: bool = false +var _busy_since: float = 0.0 +const PORT: int = 9090 +const BUSY_TIMEOUT: float = 30.0 +var _key_map: Dictionary +var _held_keys: Dictionary = {} + +func _ready() -> void: + # Ensure MCP server keeps processing even when game is paused + process_mode = Node.PROCESS_MODE_ALWAYS + _init_key_map() + _server = TCPServer.new() + var err: int = _server.listen(PORT, "127.0.0.1") + if err != OK: + push_error("McpInteractionServer: Failed to listen on port %d, error: %d" % [PORT, err]) + return + print("McpInteractionServer: Listening on 127.0.0.1:%d" % PORT) + + +func _process(_delta: float) -> void: + if _server == null: + return + + # Safety timeout: force-reset _busy if it's been stuck too long + if _busy and _busy_since > 0.0: + var elapsed: float = Time.get_ticks_msec() / 1000.0 - _busy_since + if elapsed > BUSY_TIMEOUT: + push_warning("McpInteractionServer: _busy flag stuck for %.1fs, force-resetting" % elapsed) + _busy = false + _busy_since = 0.0 + + # Accept new connections + if _server.is_connection_available(): + var new_client: StreamPeerTCP = _server.take_connection() + if new_client != null: + if _client != null: + _client.disconnect_from_host() + _client = new_client + _buffer = "" + print("McpInteractionServer: Client connected") + + # Read data from client + if _client == null: + return + + _client.poll() + var status: int = _client.get_status() + if status == StreamPeerTCP.STATUS_ERROR or status == StreamPeerTCP.STATUS_NONE: + print("McpInteractionServer: Client disconnected") + _client = null + _buffer = "" + _busy = false + _busy_since = 0.0 + return + + if status != StreamPeerTCP.STATUS_CONNECTED: + return + + var available: int = _client.get_available_bytes() + if available > 0: + var data: Array = _client.get_data(available) + if data[0] == OK: + var bytes: PackedByteArray = data[1] + _buffer += bytes.get_string_from_utf8() + + # Process complete lines (newline-delimited JSON) + while _buffer.find("\n") >= 0: + var newline_pos: int = _buffer.find("\n") + var line: String = _buffer.substr(0, newline_pos).strip_edges() + _buffer = _buffer.substr(newline_pos + 1) + if line.length() > 0: + _handle_command(line) + + +func _handle_command(json_str: String) -> void: + if _busy: + _send_response_raw({"error": "Server busy processing another command. Try again."}) + return + _busy = true + _busy_since = Time.get_ticks_msec() / 1000.0 + + var json: JSON = JSON.new() + var parse_err: int = json.parse(json_str) + if parse_err != OK: + _send_response({"error": "Invalid JSON: %s" % json.get_error_message()}) + return + + var data: Variant = json.data + if not data is Dictionary: + _send_response({"error": "Expected JSON object"}) + return + + var command: String = data.get("command", "") + var params: Dictionary = data.get("params", {}) + + match command: + # Async commands (use await) + "screenshot": + await _cmd_screenshot() + "click": + await _cmd_click(params) + "key_press": + await _cmd_key_press(params) + "eval": + await _cmd_eval(params) + "wait": + await _cmd_wait(params) + # Sync commands + "mouse_move": + _cmd_mouse_move(params) + "get_ui_elements": + _cmd_get_ui_elements() + "get_scene_tree": + _cmd_get_scene_tree() + "get_property": + _cmd_get_property(params) + "set_property": + _cmd_set_property(params) + "call_method": + _cmd_call_method(params) + "get_node_info": + _cmd_get_node_info(params) + "instantiate_scene": + _cmd_instantiate_scene(params) + "remove_node": + _cmd_remove_node(params) + "change_scene": + _cmd_change_scene(params) + "pause": + _cmd_pause(params) + "get_performance": + _cmd_get_performance(params) + "connect_signal": + _cmd_connect_signal(params) + "disconnect_signal": + _cmd_disconnect_signal(params) + "emit_signal": + _cmd_emit_signal(params) + "play_animation": + _cmd_play_animation(params) + "tween_property": + _cmd_tween_property(params) + "get_nodes_in_group": + _cmd_get_nodes_in_group(params) + "find_nodes_by_class": + _cmd_find_nodes_by_class(params) + "reparent_node": + _cmd_reparent_node(params) + # Enhanced input commands + "key_hold": + _cmd_key_hold(params) + "key_release": + _cmd_key_release(params) + "scroll": + _cmd_scroll(params) + "mouse_drag": + await _cmd_mouse_drag(params) + "gamepad": + _cmd_gamepad(params) + # Advanced runtime commands + "get_camera": + _cmd_get_camera() + "set_camera": + _cmd_set_camera(params) + "raycast": + await _cmd_raycast(params) + "get_audio": + _cmd_get_audio() + "spawn_node": + _cmd_spawn_node(params) + "set_shader_param": + _cmd_set_shader_param(params) + "audio_play": + _cmd_audio_play(params) + "audio_bus": + _cmd_audio_bus(params) + "navigate_path": + await _cmd_navigate_path(params) + "tilemap": + _cmd_tilemap(params) + "add_collision": + _cmd_add_collision(params) + "environment": + _cmd_environment(params) + "manage_group": + _cmd_manage_group(params) + "create_timer": + _cmd_create_timer(params) + "set_particles": + _cmd_set_particles(params) + "create_animation": + _cmd_create_animation(params) + "serialize_state": + _cmd_serialize_state(params) + "physics_body": + _cmd_physics_body(params) + "create_joint": + _cmd_create_joint(params) + "bone_pose": + _cmd_bone_pose(params) + "ui_theme": + _cmd_ui_theme(params) + "viewport": + _cmd_viewport(params) + "debug_draw": + _cmd_debug_draw(params) + # Batch 1: Networking + Input + System + Signals + Script + "http_request": + await _cmd_http_request(params) + "websocket": + _cmd_websocket(params) + "multiplayer": + _cmd_multiplayer(params) + "rpc": + _cmd_rpc(params) + "touch": + await _cmd_touch(params) + "input_state": + _cmd_input_state(params) + "input_action": + _cmd_input_action(params) + "list_signals": + _cmd_list_signals(params) + "await_signal": + await _cmd_await_signal(params) + "script": + _cmd_script(params) + "window": + _cmd_window(params) + "os_info": + _cmd_os_info() + "time_scale": + _cmd_time_scale(params) + "process_mode": + _cmd_process_mode(params) + "world_settings": + _cmd_world_settings(params) + # Batch 2: 3D Rendering + Lighting + Sky + Physics + "csg": + _cmd_csg(params) + "multimesh": + _cmd_multimesh(params) + "procedural_mesh": + _cmd_procedural_mesh(params) + "light_3d": + _cmd_light_3d(params) + "mesh_instance": + _cmd_mesh_instance(params) + "gridmap": + _cmd_gridmap(params) + "3d_effects": + _cmd_3d_effects(params) + "gi": + _cmd_gi(params) + "path_3d": + _cmd_path_3d(params) + "sky": + _cmd_sky(params) + "camera_attributes": + _cmd_camera_attributes(params) + "navigation_3d": + await _cmd_navigation_3d(params) + "physics_3d": + await _cmd_physics_3d(params) + # Batch 3: 2D Systems + Animation + Audio + "canvas": + _cmd_canvas(params) + "canvas_draw": + _cmd_canvas_draw(params) + "light_2d": + _cmd_light_2d(params) + "parallax": + _cmd_parallax(params) + "shape_2d": + _cmd_shape_2d(params) + "path_2d": + _cmd_path_2d(params) + "physics_2d": + await _cmd_physics_2d(params) + "animation_tree": + _cmd_animation_tree(params) + "animation_control": + _cmd_animation_control(params) + "skeleton_ik": + _cmd_skeleton_ik(params) + "audio_effect": + _cmd_audio_effect(params) + "audio_bus_layout": + _cmd_audio_bus_layout(params) + "audio_spatial": + _cmd_audio_spatial(params) + # Batch 4: Locale (runtime) + "locale": + _cmd_locale(params) + # Batch 5: UI Controls + Rendering + Resource + "ui_control": + _cmd_ui_control(params) + "ui_text": + _cmd_ui_text(params) + "ui_popup": + _cmd_ui_popup(params) + "ui_tree": + _cmd_ui_tree(params) + "ui_item_list": + _cmd_ui_item_list(params) + "ui_tabs": + _cmd_ui_tabs(params) + "ui_menu": + _cmd_ui_menu(params) + "ui_range": + _cmd_ui_range(params) + "render_settings": + _cmd_render_settings(params) + "resource": + _cmd_resource(params) + _: + _send_response({"error": "Unknown command: %s" % command}) + + +# Send response and clear busy flag +func _send_response(data: Dictionary) -> void: + _busy = false + _busy_since = 0.0 + _send_response_raw(data) + + +# Send response without clearing busy flag (used when rejecting during busy state) +func _send_response_raw(data: Dictionary) -> void: + if _client == null: + return + var json_str: String = JSON.stringify(data) + "\n" + var bytes: PackedByteArray = json_str.to_utf8_buffer() + _client.put_data(bytes) + + +# --- Screenshot --- +func _cmd_screenshot() -> void: + # Wait one frame so the viewport is fully rendered + await get_tree().process_frame + var image: Image = get_viewport().get_texture().get_image() + if image == null: + _send_response({"error": "Failed to capture screenshot"}) + return + var png_buffer: PackedByteArray = image.save_png_to_buffer() + var base64_str: String = Marshalls.raw_to_base64(png_buffer) + _send_response({ + "success": true, + "data": base64_str, + "width": image.get_width(), + "height": image.get_height() + }) + + +# --- Click --- +func _cmd_click(params: Dictionary) -> void: + var x: float = float(params.get("x", 0)) + var y: float = float(params.get("y", 0)) + var button: int = int(params.get("button", MOUSE_BUTTON_LEFT)) + + var pos: Vector2 = Vector2(x, y) + + # Mouse button press + var press_event: InputEventMouseButton = InputEventMouseButton.new() + press_event.position = pos + press_event.global_position = pos + press_event.button_index = button as MouseButton + press_event.pressed = true + Input.parse_input_event(press_event) + + # Wait a frame then release + await get_tree().process_frame + + var release_event: InputEventMouseButton = InputEventMouseButton.new() + release_event.position = pos + release_event.global_position = pos + release_event.button_index = button as MouseButton + release_event.pressed = false + Input.parse_input_event(release_event) + + _send_response({"success": true, "clicked": {"x": x, "y": y, "button": button}}) + + +# --- Key Press --- +func _cmd_key_press(params: Dictionary) -> void: + var action: String = params.get("action", "") + var key: String = params.get("key", "") + var pressed: bool = params.get("pressed", true) + + if action.length() > 0: + # Simulate an action press/release + if pressed: + Input.action_press(action) + else: + Input.action_release(action) + _send_response({"success": true, "action": action, "pressed": pressed}) + return + + if key.length() > 0: + var keycode: int = _string_to_keycode(key) + if keycode == KEY_NONE: + _send_response({"error": "Unknown key: %s" % key}) + return + + var event: InputEventKey = InputEventKey.new() + event.keycode = keycode as Key + event.physical_keycode = keycode as Key + event.pressed = pressed + Input.parse_input_event(event) + + if pressed: + # Auto-release after a frame + await get_tree().process_frame + var release_event: InputEventKey = InputEventKey.new() + release_event.keycode = keycode as Key + release_event.physical_keycode = keycode as Key + release_event.pressed = false + Input.parse_input_event(release_event) + + _send_response({"success": true, "key": key, "pressed": pressed}) + return + + _send_response({"error": "Must provide 'key' or 'action' parameter"}) + + +# --- Mouse Move --- +func _cmd_mouse_move(params: Dictionary) -> void: + var x: float = float(params.get("x", 0)) + var y: float = float(params.get("y", 0)) + var relative_x: float = float(params.get("relative_x", 0)) + var relative_y: float = float(params.get("relative_y", 0)) + + var event: InputEventMouseMotion = InputEventMouseMotion.new() + event.position = Vector2(x, y) + event.global_position = Vector2(x, y) + event.relative = Vector2(relative_x, relative_y) + Input.parse_input_event(event) + + _send_response({"success": true, "position": {"x": x, "y": y}}) + + +# --- Get UI Elements --- +func _cmd_get_ui_elements() -> void: + var elements: Array = [] + _collect_ui_elements(get_tree().root, elements) + _send_response({"success": true, "elements": elements}) + + +func _collect_ui_elements(node: Node, elements: Array) -> void: + if node is Control: + var ctrl: Control = node as Control + if ctrl.visible and ctrl.get_global_rect().size.x > 0: + var info: Dictionary = { + "name": ctrl.name, + "type": ctrl.get_class(), + "path": str(ctrl.get_path()), + "position": {"x": ctrl.global_position.x, "y": ctrl.global_position.y}, + "size": {"width": ctrl.size.x, "height": ctrl.size.y}, + } + # Get text content for common text-bearing nodes + if ctrl is Label: + info["text"] = (ctrl as Label).text + elif ctrl is Button: + info["text"] = (ctrl as Button).text + elif ctrl is LineEdit: + info["text"] = (ctrl as LineEdit).text + elif ctrl is RichTextLabel: + info["text"] = (ctrl as RichTextLabel).get_parsed_text() + + elements.append(info) + + for child in node.get_children(): + _collect_ui_elements(child, elements) + + +# --- Get Scene Tree --- +func _cmd_get_scene_tree() -> void: + var tree: Dictionary = _build_tree_node(get_tree().root) + _send_response({"success": true, "tree": tree}) + + +func _build_tree_node(node: Node) -> Dictionary: + var info: Dictionary = { + "name": node.name, + "type": node.get_class(), + } + var children_arr: Array = [] + for child in node.get_children(): + children_arr.append(_build_tree_node(child)) + if children_arr.size() > 0: + info["children"] = children_arr + return info + + +# --- Key String to Keycode --- +func _init_key_map() -> void: + _key_map = { + "A": KEY_A, "B": KEY_B, "C": KEY_C, "D": KEY_D, + "E": KEY_E, "F": KEY_F, "G": KEY_G, "H": KEY_H, + "I": KEY_I, "J": KEY_J, "K": KEY_K, "L": KEY_L, + "M": KEY_M, "N": KEY_N, "O": KEY_O, "P": KEY_P, + "Q": KEY_Q, "R": KEY_R, "S": KEY_S, "T": KEY_T, + "U": KEY_U, "V": KEY_V, "W": KEY_W, "X": KEY_X, + "Y": KEY_Y, "Z": KEY_Z, + "0": KEY_0, "1": KEY_1, "2": KEY_2, "3": KEY_3, + "4": KEY_4, "5": KEY_5, "6": KEY_6, "7": KEY_7, + "8": KEY_8, "9": KEY_9, + "SPACE": KEY_SPACE, "ENTER": KEY_ENTER, "RETURN": KEY_ENTER, + "ESCAPE": KEY_ESCAPE, "ESC": KEY_ESCAPE, + "TAB": KEY_TAB, "BACKSPACE": KEY_BACKSPACE, + "DELETE": KEY_DELETE, "INSERT": KEY_INSERT, + "HOME": KEY_HOME, "END": KEY_END, + "PAGEUP": KEY_PAGEUP, "PAGE_UP": KEY_PAGEUP, + "PAGEDOWN": KEY_PAGEDOWN, "PAGE_DOWN": KEY_PAGEDOWN, + "UP": KEY_UP, "DOWN": KEY_DOWN, "LEFT": KEY_LEFT, "RIGHT": KEY_RIGHT, + "SHIFT": KEY_SHIFT, "CTRL": KEY_CTRL, "CONTROL": KEY_CTRL, + "ALT": KEY_ALT, "CAPSLOCK": KEY_CAPSLOCK, "CAPS_LOCK": KEY_CAPSLOCK, + "F1": KEY_F1, "F2": KEY_F2, "F3": KEY_F3, "F4": KEY_F4, + "F5": KEY_F5, "F6": KEY_F6, "F7": KEY_F7, "F8": KEY_F8, + "F9": KEY_F9, "F10": KEY_F10, "F11": KEY_F11, "F12": KEY_F12, + } + +func _string_to_keycode(key_str: String) -> int: + var upper: String = key_str.to_upper() + if _key_map.has(upper): + return _key_map[upper] + if key_str.length() == 1: + return key_str.unicode_at(0) + return KEY_NONE + + +# --- Eval: Execute arbitrary GDScript at runtime --- +func _cmd_eval(params: Dictionary) -> void: + var code: String = params.get("code", "") + if code.is_empty(): + _send_response({"error": "No code provided"}) + return + + # Wrap user code in a function so we can capture the return value + var script_source: String = """extends Node + +func execute(): + var __result = null + __result = await _run() + return __result + +func _run(): +%s +""" % [_indent_code(code)] + + var script: GDScript = GDScript.new() + script.source_code = script_source + var err: int = script.reload() + if err != OK: + _send_response({"error": "Failed to compile GDScript (error %d). Check syntax." % err}) + return + + var temp_node: Node = Node.new() + temp_node.set_script(script) + # Allow eval to work even when game is paused + temp_node.process_mode = Node.PROCESS_MODE_ALWAYS + add_child(temp_node) + + var result: Variant = null + if temp_node.has_method("execute"): + result = await temp_node.execute() + + temp_node.queue_free() + _send_response({"success": true, "result": _variant_to_json(result)}) + + +func _indent_code(code: String) -> String: + var lines: PackedStringArray = code.split("\n") + var indented: String = "" + for line in lines: + indented += "\t" + line + "\n" + return indented + + +# --- Get Property --- +func _cmd_get_property(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var property: String = params.get("property", "") + if node_path.is_empty() or property.is_empty(): + _send_response({"error": "node_path and property are required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + var value: Variant = node.get(property) + _send_response({"success": true, "value": _variant_to_json(value), "property": property, "node_path": node_path}) + + +# --- Set Property --- +func _cmd_set_property(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var property: String = params.get("property", "") + if node_path.is_empty() or property.is_empty(): + _send_response({"error": "node_path and property are required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + var raw_value: Variant = params.get("value", null) + var type_hint: String = params.get("type_hint", "") + var value: Variant + if type_hint.is_empty(): + value = _json_to_variant_for_property(node, property, raw_value) + else: + value = _json_to_variant(raw_value, type_hint) + node.set(property, value) + _send_response({"success": true, "node_path": node_path, "property": property, "value": _variant_to_json(node.get(property))}) + + +# --- Call Method --- +func _cmd_call_method(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var method_name: String = params.get("method", "") + if node_path.is_empty() or method_name.is_empty(): + _send_response({"error": "node_path and method are required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + if not node.has_method(method_name): + _send_response({"error": "Method not found: %s on node %s" % [method_name, node_path]}) + return + + var args: Array = params.get("args", []) + var result: Variant = node.callv(method_name, args) + _send_response({"success": true, "result": _variant_to_json(result)}) + + +# --- Get Node Info --- +func _cmd_get_node_info(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _send_response({"error": "node_path is required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + var properties: Array = [] + for prop in node.get_property_list(): + var prop_dict: Dictionary = prop + if prop_dict.get("usage", 0) & PROPERTY_USAGE_EDITOR: + properties.append({ + "name": prop_dict.get("name", ""), + "type": prop_dict.get("type", 0), + "value": _variant_to_json(node.get(prop_dict.get("name", ""))) + }) + + var signals: Array = [] + for sig in node.get_signal_list(): + var sig_dict: Dictionary = sig + signals.append(sig_dict.get("name", "")) + + var methods: Array = [] + for m in node.get_method_list(): + var m_dict: Dictionary = m + if not str(m_dict.get("name", "")).begins_with("_"): + methods.append(m_dict.get("name", "")) + + var children: Array = [] + for child in node.get_children(): + children.append({ + "name": child.name, + "type": child.get_class(), + "path": str(child.get_path()) + }) + + _send_response({ + "success": true, + "class": node.get_class(), + "name": node.name, + "path": str(node.get_path()), + "properties": properties, + "signals": signals, + "methods": methods, + "children": children + }) + + +# --- Instantiate Scene --- +func _cmd_instantiate_scene(params: Dictionary) -> void: + var scene_path: String = params.get("scene_path", "") + var parent_path: String = params.get("parent_path", "/root") + if scene_path.is_empty(): + _send_response({"error": "scene_path is required"}) + return + + var packed: PackedScene = load(scene_path) as PackedScene + if packed == null: + _send_response({"error": "Failed to load scene: %s" % scene_path}) + return + + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent node not found: %s" % parent_path}) + return + + var instance: Node = packed.instantiate() + parent.add_child(instance) + _send_response({"success": true, "instance_name": instance.name, "instance_path": str(instance.get_path())}) + + +# --- Remove Node --- +func _cmd_remove_node(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _send_response({"error": "node_path is required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + var node_name: String = node.name + node.queue_free() + _send_response({"success": true, "removed": node_name}) + + +# --- Change Scene --- +func _cmd_change_scene(params: Dictionary) -> void: + var scene_path: String = params.get("scene_path", "") + if scene_path.is_empty(): + _send_response({"error": "scene_path is required"}) + return + + var err: int = get_tree().change_scene_to_file(scene_path) + if err != OK: + _send_response({"error": "Failed to change scene. Error code: %d" % err}) + return + + _send_response({"success": true, "scene": scene_path}) + + +# --- Pause --- +func _cmd_pause(params: Dictionary) -> void: + var paused: bool = params.get("paused", true) + get_tree().paused = paused + _send_response({"success": true, "paused": paused}) + + +# --- Get Performance --- +func _cmd_get_performance(_params: Dictionary) -> void: + _send_response({ + "success": true, + "fps": Performance.get_monitor(Performance.TIME_FPS), + "frame_time": Performance.get_monitor(Performance.TIME_PROCESS), + "physics_frame_time": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS), + "memory_static": Performance.get_monitor(Performance.MEMORY_STATIC), + "memory_static_max": Performance.get_monitor(Performance.MEMORY_STATIC_MAX), + "object_count": Performance.get_monitor(Performance.OBJECT_COUNT), + "object_node_count": Performance.get_monitor(Performance.OBJECT_NODE_COUNT), + "object_orphan_node_count": Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT), + "render_total_objects": Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME), + "render_total_draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME) + }) + + +# --- Wait N Frames --- +func _cmd_wait(params: Dictionary) -> void: + var frames: int = int(params.get("frames", 1)) + for i in frames: + await get_tree().process_frame + _send_response({"success": true, "waited_frames": frames}) + + +# --- Helper: Convert Godot Variant to JSON-safe value --- +func _variant_to_json(value: Variant) -> Variant: + if value == null: + return null + if value is bool or value is int or value is float or value is String: + return value + if value is Vector2: + return {"x": value.x, "y": value.y} + if value is Vector3: + return {"x": value.x, "y": value.y, "z": value.z} + if value is Vector2i: + return {"x": value.x, "y": value.y} + if value is Vector3i: + return {"x": value.x, "y": value.y, "z": value.z} + if value is Color: + return {"r": value.r, "g": value.g, "b": value.b, "a": value.a} + if value is Quaternion: + return {"x": value.x, "y": value.y, "z": value.z, "w": value.w} + if value is Basis: + return { + "x": _variant_to_json(value.x), + "y": _variant_to_json(value.y), + "z": _variant_to_json(value.z) + } + if value is Transform3D: + return { + "basis": _variant_to_json(value.basis), + "origin": _variant_to_json(value.origin) + } + if value is Transform2D: + return { + "x": _variant_to_json(value.x), + "y": _variant_to_json(value.y), + "origin": _variant_to_json(value.origin) + } + if value is Rect2: + return {"position": _variant_to_json(value.position), "size": _variant_to_json(value.size)} + if value is AABB: + return {"position": _variant_to_json(value.position), "size": _variant_to_json(value.size)} + if value is NodePath: + return str(value) + if value is StringName: + return str(value) + # Packed arrays - serialize as JSON arrays instead of str() fallback + if value is PackedByteArray: + var arr: Array = [] + for item in value: + arr.append(item) + return arr + if value is PackedInt32Array or value is PackedInt64Array: + var arr: Array = [] + for item in value: + arr.append(item) + return arr + if value is PackedFloat32Array or value is PackedFloat64Array: + var arr: Array = [] + for item in value: + arr.append(item) + return arr + if value is PackedStringArray: + var arr: Array = [] + for item in value: + arr.append(item) + return arr + if value is PackedVector2Array: + var arr: Array = [] + for item in value: + arr.append({"x": item.x, "y": item.y}) + return arr + if value is PackedVector3Array: + var arr: Array = [] + for item in value: + arr.append({"x": item.x, "y": item.y, "z": item.z}) + return arr + if value is PackedColorArray: + var arr: Array = [] + for item in value: + arr.append({"r": item.r, "g": item.g, "b": item.b, "a": item.a}) + return arr + if value is Array: + var arr: Array = [] + for item in value: + arr.append(_variant_to_json(item)) + return arr + if value is Dictionary: + var dict: Dictionary = {} + for key in value: + dict[str(key)] = _variant_to_json(value[key]) + return dict + if value is Object: + if value is Node: + return {"_type": "Node", "class": value.get_class(), "name": (value as Node).name, "path": str((value as Node).get_path())} + if value is Resource: + return {"_type": "Resource", "class": value.get_class(), "path": (value as Resource).resource_path} + return {"_type": "Object", "class": value.get_class(), "id": value.get_instance_id()} + # Fallback: convert to string + return str(value) + + +# --- Helper: Convert JSON value back to Godot Variant --- +func _json_to_variant(value: Variant, type_hint: String = "") -> Variant: + if value == null: + return null + if value is Dictionary: + var dict: Dictionary = value + # Explicit type hints take priority + match type_hint: + "Vector2": + return Vector2(float(dict.get("x", 0)), float(dict.get("y", 0))) + "Vector2i": + return Vector2i(int(dict.get("x", 0)), int(dict.get("y", 0))) + "Vector3": + return Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0))) + "Vector3i": + return Vector3i(int(dict.get("x", 0)), int(dict.get("y", 0)), int(dict.get("z", 0))) + "Color": + return Color(float(dict.get("r", 0)), float(dict.get("g", 0)), float(dict.get("b", 0)), float(dict.get("a", 1))) + "Quaternion": + return Quaternion(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0)), float(dict.get("w", 1))) + "Rect2": + var pos: Dictionary = dict.get("position", {"x": 0, "y": 0}) + var sz: Dictionary = dict.get("size", {"x": 0, "y": 0}) + return Rect2(float(pos.get("x", 0)), float(pos.get("y", 0)), float(sz.get("x", 0)), float(sz.get("y", 0))) + "AABB": + var aabb_pos: Dictionary = dict.get("position", {"x": 0, "y": 0, "z": 0}) + var aabb_sz: Dictionary = dict.get("size", {"x": 0, "y": 0, "z": 0}) + return AABB( + Vector3(float(aabb_pos.get("x", 0)), float(aabb_pos.get("y", 0)), float(aabb_pos.get("z", 0))), + Vector3(float(aabb_sz.get("x", 0)), float(aabb_sz.get("y", 0)), float(aabb_sz.get("z", 0))) + ) + "Basis": + var bx: Dictionary = dict.get("x", {"x": 1, "y": 0, "z": 0}) + var by: Dictionary = dict.get("y", {"x": 0, "y": 1, "z": 0}) + var bz: Dictionary = dict.get("z", {"x": 0, "y": 0, "z": 1}) + return Basis( + Vector3(float(bx.get("x", 0)), float(bx.get("y", 0)), float(bx.get("z", 0))), + Vector3(float(by.get("x", 0)), float(by.get("y", 0)), float(by.get("z", 0))), + Vector3(float(bz.get("x", 0)), float(bz.get("y", 0)), float(bz.get("z", 0))) + ) + "Transform3D": + var basis_dict: Dictionary = dict.get("basis", {}) + var origin_dict: Dictionary = dict.get("origin", {"x": 0, "y": 0, "z": 0}) + var basis: Basis = _json_to_variant(basis_dict, "Basis") if basis_dict.size() > 0 else Basis.IDENTITY + var origin: Vector3 = Vector3(float(origin_dict.get("x", 0)), float(origin_dict.get("y", 0)), float(origin_dict.get("z", 0))) + return Transform3D(basis, origin) + "Transform2D": + var tx: Dictionary = dict.get("x", {"x": 1, "y": 0}) + var ty: Dictionary = dict.get("y", {"x": 0, "y": 1}) + var t_origin: Dictionary = dict.get("origin", {"x": 0, "y": 0}) + return Transform2D( + Vector2(float(tx.get("x", 0)), float(tx.get("y", 0))), + Vector2(float(ty.get("x", 0)), float(ty.get("y", 0))), + Vector2(float(t_origin.get("x", 0)), float(t_origin.get("y", 0))) + ) + # Auto-detect from dict keys + if dict.has("basis") and dict.has("origin"): + return _json_to_variant(dict, "Transform3D") + if dict.has("r") and dict.has("g") and dict.has("b"): + return Color(float(dict.get("r", 0)), float(dict.get("g", 0)), float(dict.get("b", 0)), float(dict.get("a", 1))) + if dict.has("x") and dict.has("y") and dict.has("z") and dict.has("w"): + return Quaternion(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0)), float(dict.get("w", 1))) + if dict.has("position") and dict.has("size"): + var pos_dict: Dictionary = dict["position"] + var size_dict: Dictionary = dict["size"] + if pos_dict.has("z") or size_dict.has("z"): + return _json_to_variant(dict, "AABB") + return _json_to_variant(dict, "Rect2") + if dict.has("x") and dict.has("y") and dict.has("z"): + return Vector3(float(dict.get("x", 0)), float(dict.get("y", 0)), float(dict.get("z", 0))) + if dict.has("x") and dict.has("y") and dict.size() == 2: + return Vector2(float(dict.get("x", 0)), float(dict.get("y", 0))) + return value + return value + + +# --- Helper: Convert JSON value using node's property type info --- +func _json_to_variant_for_property(node: Node, property: String, value: Variant) -> Variant: + for prop in node.get_property_list(): + if prop["name"] == property: + var type_id: int = prop.get("type", 0) + match type_id: + TYPE_VECTOR2: + return _json_to_variant(value, "Vector2") + TYPE_VECTOR2I: + return _json_to_variant(value, "Vector2i") + TYPE_VECTOR3: + return _json_to_variant(value, "Vector3") + TYPE_VECTOR3I: + return _json_to_variant(value, "Vector3i") + TYPE_COLOR: + return _json_to_variant(value, "Color") + TYPE_QUATERNION: + return _json_to_variant(value, "Quaternion") + TYPE_RECT2: + return _json_to_variant(value, "Rect2") + TYPE_AABB: + return _json_to_variant(value, "AABB") + TYPE_BASIS: + return _json_to_variant(value, "Basis") + TYPE_TRANSFORM3D: + return _json_to_variant(value, "Transform3D") + TYPE_TRANSFORM2D: + return _json_to_variant(value, "Transform2D") + TYPE_BOOL: + if value is String: + return value.to_lower() == "true" + return bool(value) + TYPE_INT: + return int(value) + TYPE_FLOAT: + return float(value) + break + # No type info found, use raw value or auto-detect + return _json_to_variant(value) + + +# --- Connect Signal --- +func _cmd_connect_signal(params: Dictionary) -> void: + 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", "") + if node_path.is_empty() or signal_name.is_empty() or target_path.is_empty() or method_name.is_empty(): + _send_response({"error": "node_path, signal_name, target_path, and method are required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Source node not found: %s" % node_path}) + return + + var target: Node = get_tree().root.get_node_or_null(target_path) + if target == null: + _send_response({"error": "Target node not found: %s" % target_path}) + return + + if not node.has_signal(signal_name): + _send_response({"error": "Signal '%s' not found on node %s" % [signal_name, node_path]}) + return + + if not target.has_method(method_name): + _send_response({"error": "Method '%s' not found on target %s" % [method_name, target_path]}) + return + + if node.is_connected(signal_name, Callable(target, method_name)): + _send_response({"error": "Signal already connected"}) + return + + node.connect(signal_name, Callable(target, method_name)) + _send_response({"success": true, "signal": signal_name, "from": node_path, "to": target_path, "method": method_name}) + + +# --- Disconnect Signal --- +func _cmd_disconnect_signal(params: Dictionary) -> void: + 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", "") + if node_path.is_empty() or signal_name.is_empty() or target_path.is_empty() or method_name.is_empty(): + _send_response({"error": "node_path, signal_name, target_path, and method are required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Source node not found: %s" % node_path}) + return + + var target: Node = get_tree().root.get_node_or_null(target_path) + if target == null: + _send_response({"error": "Target node not found: %s" % target_path}) + return + + var callable: Callable = Callable(target, method_name) + if not node.is_connected(signal_name, callable): + _send_response({"error": "Signal is not connected"}) + return + + node.disconnect(signal_name, callable) + _send_response({"success": true, "disconnected": signal_name, "from": node_path, "to": target_path, "method": method_name}) + + +# --- Emit Signal --- +func _cmd_emit_signal(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var signal_name: String = params.get("signal_name", "") + if node_path.is_empty() or signal_name.is_empty(): + _send_response({"error": "node_path and signal_name are required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + if not node.has_signal(signal_name): + _send_response({"error": "Signal '%s' not found on node %s" % [signal_name, node_path]}) + return + + var args: Array = params.get("args", []) + var call_args: Array = [signal_name] + call_args.append_array(args) + node.callv("emit_signal", call_args) + _send_response({"success": true, "emitted": signal_name, "node": node_path, "arg_count": args.size()}) + + +# --- Play Animation --- +func _cmd_play_animation(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _send_response({"error": "node_path is required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + if not node is AnimationPlayer: + _send_response({"error": "Node is not an AnimationPlayer: %s (is %s)" % [node_path, node.get_class()]}) + return + + var anim_player: AnimationPlayer = node as AnimationPlayer + var action: String = params.get("action", "play") + + match action: + "play": + var animation: String = params.get("animation", "") + if animation.is_empty(): + _send_response({"error": "animation name is required for play action"}) + return + if not anim_player.has_animation(animation): + _send_response({"error": "Animation '%s' not found. Available: %s" % [animation, str(anim_player.get_animation_list())]}) + return + anim_player.play(animation) + _send_response({"success": true, "action": "play", "animation": animation}) + "stop": + anim_player.stop() + _send_response({"success": true, "action": "stop"}) + "pause": + anim_player.pause() + _send_response({"success": true, "action": "pause"}) + "get_list": + var anims: Array = [] + for anim_name in anim_player.get_animation_list(): + anims.append(str(anim_name)) + _send_response({"success": true, "animations": anims, "current": anim_player.current_animation, "playing": anim_player.is_playing()}) + _: + _send_response({"error": "Unknown animation action: %s. Use play, stop, pause, or get_list" % action}) + + +# --- Tween Property --- +func _cmd_tween_property(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var property: String = params.get("property", "") + if node_path.is_empty() or property.is_empty(): + _send_response({"error": "node_path and property are required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + var final_value: Variant = _json_to_variant_for_property(node, property, params.get("final_value", null)) + var duration: float = float(params.get("duration", 1.0)) + var trans_type: int = int(params.get("trans_type", 0)) # Tween.TRANS_LINEAR + var ease_type: int = int(params.get("ease_type", 2)) # Tween.EASE_IN_OUT + + var tween: Tween = create_tween() + tween.tween_property(node, property, final_value, duration).set_trans(trans_type).set_ease(ease_type) + _send_response({"success": true, "node": node_path, "property": property, "duration": duration}) + + +# --- Get Nodes In Group --- +func _cmd_get_nodes_in_group(params: Dictionary) -> void: + var group_name: String = params.get("group", "") + if group_name.is_empty(): + _send_response({"error": "group is required"}) + return + + var nodes: Array = get_tree().get_nodes_in_group(group_name) + var result: Array = [] + for node in nodes: + result.append({ + "name": node.name, + "type": node.get_class(), + "path": str(node.get_path()) + }) + _send_response({"success": true, "group": group_name, "count": result.size(), "nodes": result}) + + +# --- Find Nodes By Class --- +func _cmd_find_nodes_by_class(params: Dictionary) -> void: + var class_filter: String = params.get("class_name", "") + if class_filter.is_empty(): + _send_response({"error": "class_name is required"}) + return + + var root_path: String = params.get("root_path", "/root") + var root_node: Node = get_tree().root.get_node_or_null(root_path) + if root_node == null: + _send_response({"error": "Root node not found: %s" % root_path}) + return + + var found: Array = [] + _find_by_class_recursive(root_node, class_filter, found) + _send_response({"success": true, "class_name": class_filter, "count": found.size(), "nodes": found}) + + +func _find_by_class_recursive(node: Node, class_filter: String, results: Array) -> void: + if node.get_class() == class_filter or node.is_class(class_filter): + results.append({ + "name": node.name, + "type": node.get_class(), + "path": str(node.get_path()) + }) + for child in node.get_children(): + _find_by_class_recursive(child, class_filter, results) + + +# --- Reparent Node --- +func _cmd_reparent_node(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var new_parent_path: String = params.get("new_parent_path", "") + if node_path.is_empty() or new_parent_path.is_empty(): + _send_response({"error": "node_path and new_parent_path are required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + var new_parent: Node = get_tree().root.get_node_or_null(new_parent_path) + if new_parent == null: + _send_response({"error": "New parent not found: %s" % new_parent_path}) + return + + var keep_global: bool = params.get("keep_global_transform", true) + node.reparent(new_parent, keep_global) + _send_response({"success": true, "node": node.name, "new_parent": new_parent_path, "new_path": str(node.get_path())}) + + +# --- Key Hold (no auto-release) --- +func _cmd_key_hold(params: Dictionary) -> void: + var action: String = params.get("action", "") + var key: String = params.get("key", "") + + if action.length() > 0: + Input.action_press(action) + _held_keys["action:" + action] = true + _send_response({"success": true, "held": action, "type": "action"}) + return + + if key.length() > 0: + var keycode: int = _string_to_keycode(key) + if keycode == KEY_NONE: + _send_response({"error": "Unknown key: %s" % key}) + return + var event: InputEventKey = InputEventKey.new() + event.keycode = keycode as Key + event.physical_keycode = keycode as Key + event.pressed = true + Input.parse_input_event(event) + _held_keys["key:" + key.to_upper()] = keycode + _send_response({"success": true, "held": key, "type": "key"}) + return + + _send_response({"error": "Must provide 'key' or 'action' parameter"}) + + +# --- Key Release --- +func _cmd_key_release(params: Dictionary) -> void: + var action: String = params.get("action", "") + var key: String = params.get("key", "") + + if action.length() > 0: + Input.action_release(action) + _held_keys.erase("action:" + action) + _send_response({"success": true, "released": action, "type": "action"}) + return + + if key.length() > 0: + var keycode: int = _string_to_keycode(key) + if keycode == KEY_NONE: + _send_response({"error": "Unknown key: %s" % key}) + return + var event: InputEventKey = InputEventKey.new() + event.keycode = keycode as Key + event.physical_keycode = keycode as Key + event.pressed = false + Input.parse_input_event(event) + _held_keys.erase("key:" + key.to_upper()) + _send_response({"success": true, "released": key, "type": "key"}) + return + + _send_response({"error": "Must provide 'key' or 'action' parameter"}) + + +# --- Scroll --- +func _cmd_scroll(params: Dictionary) -> void: + var x: float = float(params.get("x", 0)) + var y: float = float(params.get("y", 0)) + var direction: String = params.get("direction", "up") + var amount: int = int(params.get("amount", 1)) + + var button_index: int = MOUSE_BUTTON_WHEEL_UP + match direction: + "down": + button_index = MOUSE_BUTTON_WHEEL_DOWN + "left": + button_index = MOUSE_BUTTON_WHEEL_LEFT + "right": + button_index = MOUSE_BUTTON_WHEEL_RIGHT + + for i in amount: + var press_event: InputEventMouseButton = InputEventMouseButton.new() + press_event.position = Vector2(x, y) + press_event.global_position = Vector2(x, y) + press_event.button_index = button_index as MouseButton + press_event.pressed = true + press_event.factor = 1.0 + Input.parse_input_event(press_event) + + var release_event: InputEventMouseButton = InputEventMouseButton.new() + release_event.position = Vector2(x, y) + release_event.global_position = Vector2(x, y) + release_event.button_index = button_index as MouseButton + release_event.pressed = false + Input.parse_input_event(release_event) + + _send_response({"success": true, "direction": direction, "amount": amount, "position": {"x": x, "y": y}}) + + +# --- Mouse Drag --- +func _cmd_mouse_drag(params: Dictionary) -> void: + var from_x: float = float(params.get("from_x", 0)) + var from_y: float = float(params.get("from_y", 0)) + var to_x: float = float(params.get("to_x", 0)) + var to_y: float = float(params.get("to_y", 0)) + var button: int = int(params.get("button", MOUSE_BUTTON_LEFT)) + var steps: int = int(params.get("steps", 10)) + if steps < 1: + steps = 1 + + var from_pos: Vector2 = Vector2(from_x, from_y) + var to_pos: Vector2 = Vector2(to_x, to_y) + + # Press at start position + var press_event: InputEventMouseButton = InputEventMouseButton.new() + press_event.position = from_pos + press_event.global_position = from_pos + press_event.button_index = button as MouseButton + press_event.pressed = true + Input.parse_input_event(press_event) + + # Lerp position over steps frames + for i in steps: + await get_tree().process_frame + var t: float = float(i + 1) / float(steps) + var current_pos: Vector2 = from_pos.lerp(to_pos, t) + var move_event: InputEventMouseMotion = InputEventMouseMotion.new() + move_event.position = current_pos + move_event.global_position = current_pos + move_event.relative = (to_pos - from_pos) / float(steps) + move_event.button_mask = MOUSE_BUTTON_MASK_LEFT if button == MOUSE_BUTTON_LEFT else 0 + Input.parse_input_event(move_event) + + # Release at end position + var release_event: InputEventMouseButton = InputEventMouseButton.new() + release_event.position = to_pos + release_event.global_position = to_pos + release_event.button_index = button as MouseButton + release_event.pressed = false + Input.parse_input_event(release_event) + + _send_response({"success": true, "from": {"x": from_x, "y": from_y}, "to": {"x": to_x, "y": to_y}, "steps": steps}) + + +# --- Gamepad --- +func _cmd_gamepad(params: Dictionary) -> void: + var input_type: String = params.get("type", "button") + var index: int = int(params.get("index", 0)) + var value: float = float(params.get("value", 0)) + var device: int = int(params.get("device", 0)) + + if input_type == "button": + var event: InputEventJoypadButton = InputEventJoypadButton.new() + event.device = device + event.button_index = index as JoyButton + event.pressed = value > 0.5 + event.pressure = value + Input.parse_input_event(event) + _send_response({"success": true, "type": "button", "index": index, "pressed": event.pressed, "device": device}) + elif input_type == "axis": + var event: InputEventJoypadMotion = InputEventJoypadMotion.new() + event.device = device + event.axis = index as JoyAxis + event.axis_value = value + Input.parse_input_event(event) + _send_response({"success": true, "type": "axis", "index": index, "value": value, "device": device}) + else: + _send_response({"error": "Invalid type: %s. Use 'button' or 'axis'" % input_type}) + + +# --- Get Camera --- +func _cmd_get_camera() -> void: + var result: Dictionary = {"success": true} + + var cam2d: Camera2D = get_viewport().get_camera_2d() + if cam2d != null: + result["camera_2d"] = { + "position": {"x": cam2d.global_position.x, "y": cam2d.global_position.y}, + "rotation": cam2d.global_rotation, + "zoom": {"x": cam2d.zoom.x, "y": cam2d.zoom.y}, + "path": str(cam2d.get_path()) + } + + var cam3d: Camera3D = get_viewport().get_camera_3d() + if cam3d != null: + result["camera_3d"] = { + "position": {"x": cam3d.global_position.x, "y": cam3d.global_position.y, "z": cam3d.global_position.z}, + "rotation": {"x": rad_to_deg(cam3d.global_rotation.x), "y": rad_to_deg(cam3d.global_rotation.y), "z": rad_to_deg(cam3d.global_rotation.z)}, + "fov": cam3d.fov, + "path": str(cam3d.get_path()) + } + + if cam2d == null and cam3d == null: + result["error"] = "No active camera found" + result["success"] = false + + _send_response(result) + + +# --- Set Camera --- +func _cmd_set_camera(params: Dictionary) -> void: + var cam2d: Camera2D = get_viewport().get_camera_2d() + var cam3d: Camera3D = get_viewport().get_camera_3d() + + if cam2d == null and cam3d == null: + _send_response({"error": "No active camera found"}) + return + + if cam2d != null: + if params.has("position"): + var pos: Dictionary = params["position"] + cam2d.global_position = Vector2(float(pos.get("x", cam2d.global_position.x)), float(pos.get("y", cam2d.global_position.y))) + if params.has("rotation"): + var rot: Dictionary = params["rotation"] + cam2d.global_rotation = deg_to_rad(float(rot.get("z", rad_to_deg(cam2d.global_rotation)))) + if params.has("zoom"): + var z: Dictionary = params["zoom"] + cam2d.zoom = Vector2(float(z.get("x", cam2d.zoom.x)), float(z.get("y", cam2d.zoom.y))) + _send_response({"success": true, "camera": "2d", "position": _variant_to_json(cam2d.global_position), "zoom": _variant_to_json(cam2d.zoom)}) + return + + if cam3d != null: + if params.has("position"): + var pos: Dictionary = params["position"] + cam3d.global_position = Vector3(float(pos.get("x", cam3d.global_position.x)), float(pos.get("y", cam3d.global_position.y)), float(pos.get("z", cam3d.global_position.z))) + if params.has("rotation"): + var rot: Dictionary = params["rotation"] + cam3d.global_rotation = Vector3(deg_to_rad(float(rot.get("x", rad_to_deg(cam3d.global_rotation.x)))), deg_to_rad(float(rot.get("y", rad_to_deg(cam3d.global_rotation.y)))), deg_to_rad(float(rot.get("z", rad_to_deg(cam3d.global_rotation.z))))) + if params.has("fov"): + cam3d.fov = float(params["fov"]) + _send_response({"success": true, "camera": "3d", "position": _variant_to_json(cam3d.global_position), "rotation": _variant_to_json(cam3d.global_rotation)}) + return + + +# --- Raycast --- +func _cmd_raycast(params: Dictionary) -> void: + var from_dict: Dictionary = params.get("from", {}) + var to_dict: Dictionary = params.get("to", {}) + var collision_mask: int = int(params.get("collision_mask", 0xFFFFFFFF)) + + # Determine 2D vs 3D based on whether z is present + var is_3d: bool = from_dict.has("z") or to_dict.has("z") + + if is_3d: + var from_pos: Vector3 = Vector3(float(from_dict.get("x", 0)), float(from_dict.get("y", 0)), float(from_dict.get("z", 0))) + var to_pos: Vector3 = Vector3(float(to_dict.get("x", 0)), float(to_dict.get("y", 0)), float(to_dict.get("z", 0))) + + # Wait a frame to ensure physics state is available + await get_tree().process_frame + + var space_state: PhysicsDirectSpaceState3D = get_viewport().world_3d.direct_space_state + var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from_pos, to_pos, collision_mask) + var result: Dictionary = space_state.intersect_ray(query) + + if result.is_empty(): + _send_response({"success": true, "hit": false, "mode": "3d"}) + else: + _send_response({ + "success": true, "hit": true, "mode": "3d", + "position": _variant_to_json(result["position"]), + "normal": _variant_to_json(result["normal"]), + "collider_path": str(result["collider"].get_path()) if result.has("collider") and result["collider"] is Node else "", + "collider_class": result["collider"].get_class() if result.has("collider") else "", + }) + else: + var from_pos: Vector2 = Vector2(float(from_dict.get("x", 0)), float(from_dict.get("y", 0))) + var to_pos: Vector2 = Vector2(float(to_dict.get("x", 0)), float(to_dict.get("y", 0))) + + await get_tree().process_frame + + var space_state: PhysicsDirectSpaceState2D = get_viewport().world_2d.direct_space_state + var query: PhysicsRayQueryParameters2D = PhysicsRayQueryParameters2D.create(from_pos, to_pos, collision_mask) + var result: Dictionary = space_state.intersect_ray(query) + + if result.is_empty(): + _send_response({"success": true, "hit": false, "mode": "2d"}) + else: + _send_response({ + "success": true, "hit": true, "mode": "2d", + "position": _variant_to_json(result["position"]), + "normal": _variant_to_json(result["normal"]), + "collider_path": str(result["collider"].get_path()) if result.has("collider") and result["collider"] is Node else "", + "collider_class": result["collider"].get_class() if result.has("collider") else "", + }) + + +# --- Get Audio --- +func _cmd_get_audio() -> void: + var buses: Array = [] + for i in AudioServer.bus_count: + buses.append({ + "name": AudioServer.get_bus_name(i), + "volume_db": AudioServer.get_bus_volume_db(i), + "mute": AudioServer.is_bus_mute(i), + "solo": AudioServer.is_bus_solo(i), + }) + + var players: Array = [] + _find_audio_players(get_tree().root, players) + + _send_response({"success": true, "buses": buses, "players": players}) + + +func _find_audio_players(node: Node, results: Array) -> void: + if node is AudioStreamPlayer: + var p: AudioStreamPlayer = node as AudioStreamPlayer + results.append({"path": str(p.get_path()), "type": "AudioStreamPlayer", "playing": p.playing, "bus": p.bus}) + elif node is AudioStreamPlayer2D: + var p: AudioStreamPlayer2D = node as AudioStreamPlayer2D + results.append({"path": str(p.get_path()), "type": "AudioStreamPlayer2D", "playing": p.playing, "bus": p.bus}) + elif node is AudioStreamPlayer3D: + var p: AudioStreamPlayer3D = node as AudioStreamPlayer3D + results.append({"path": str(p.get_path()), "type": "AudioStreamPlayer3D", "playing": p.playing, "bus": p.bus}) + for child in node.get_children(): + _find_audio_players(child, results) + + +# --- Spawn Node --- +func _cmd_spawn_node(params: Dictionary) -> void: + var type_name: String = params.get("type", "") + var node_name: String = params.get("name", "") + var parent_path: String = params.get("parent_path", "/root") + + if type_name.is_empty(): + _send_response({"error": "type is required"}) + return + + if not ClassDB.class_exists(type_name): + _send_response({"error": "Unknown class: %s" % type_name}) + return + + if not ClassDB.is_parent_class(type_name, "Node") and type_name != "Node": + _send_response({"error": "Class '%s' is not a Node type" % type_name}) + return + + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent node not found: %s" % parent_path}) + return + + var instance: Node = ClassDB.instantiate(type_name) as Node + if instance == null: + _send_response({"error": "Failed to instantiate: %s" % type_name}) + return + + if node_name.length() > 0: + instance.name = node_name + + # Apply properties if provided + var properties: Dictionary = params.get("properties", {}) + for prop_name in properties: + var raw_value: Variant = properties[prop_name] + var value: Variant = _json_to_variant_for_property(instance, prop_name, raw_value) + instance.set(prop_name, value) + + parent.add_child(instance) + _send_response({"success": true, "name": instance.name, "type": type_name, "path": str(instance.get_path())}) + + +# --- Set Shader Parameter --- +func _cmd_set_shader_param(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var param_name: String = params.get("param_name", "") + if node_path.is_empty() or param_name.is_empty(): + _send_response({"error": "node_path and param_name are required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + var material: Material = null + # Try material_override first (MeshInstance3D/2D) + if node.get("material_override") != null: + material = node.get("material_override") + # Try surface override material (MeshInstance3D) + elif node.has_method("get_surface_override_material"): + material = node.get_surface_override_material(0) + # Try material property (CanvasItem, e.g. Sprite2D) + elif node.get("material") != null: + material = node.get("material") + + if material == null or not material is ShaderMaterial: + _send_response({"error": "No ShaderMaterial found on node: %s" % node_path}) + return + + var shader_mat: ShaderMaterial = material as ShaderMaterial + var raw_value: Variant = params.get("value", null) + var type_hint: String = params.get("type_hint", "") + var value: Variant = _json_to_variant(raw_value, type_hint) + shader_mat.set_shader_parameter(param_name, value) + _send_response({"success": true, "node_path": node_path, "param_name": param_name, "value": _variant_to_json(shader_mat.get_shader_parameter(param_name))}) + + +# --- Audio Play --- +func _cmd_audio_play(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var action: String = params.get("action", "play") + if node_path.is_empty(): + _send_response({"error": "node_path is required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + if not (node is AudioStreamPlayer or node is AudioStreamPlayer2D or node is AudioStreamPlayer3D): + _send_response({"error": "Node is not an AudioStreamPlayer: %s (is %s)" % [node_path, node.get_class()]}) + return + + # Optionally load a new stream + if params.has("stream"): + var stream_path: String = params["stream"] + var stream: AudioStream = load(stream_path) as AudioStream + if stream == null: + _send_response({"error": "Failed to load audio stream: %s" % stream_path}) + return + node.set("stream", stream) + + # Set optional properties + if params.has("volume"): + var linear_vol: float = float(params["volume"]) + node.set("volume_db", linear_to_db(clampf(linear_vol, 0.0, 1.0))) + if params.has("pitch"): + node.set("pitch_scale", float(params["pitch"])) + if params.has("bus"): + node.set("bus", params["bus"]) + + match action: + "play": + var from_pos: float = float(params.get("from_position", 0.0)) + node.call("play", from_pos) + _send_response({"success": true, "action": "play", "node_path": node_path}) + "stop": + node.call("stop") + _send_response({"success": true, "action": "stop", "node_path": node_path}) + "pause": + node.set("stream_paused", true) + _send_response({"success": true, "action": "pause", "node_path": node_path}) + "resume": + node.set("stream_paused", false) + _send_response({"success": true, "action": "resume", "node_path": node_path}) + _: + _send_response({"error": "Unknown audio action: %s. Use play, stop, pause, or resume" % action}) + + +# --- Audio Bus --- +func _cmd_audio_bus(params: Dictionary) -> void: + var bus_name: String = params.get("bus_name", "Master") + var bus_idx: int = AudioServer.get_bus_index(bus_name) + if bus_idx == -1: + _send_response({"error": "Audio bus not found: %s" % bus_name}) + return + + if params.has("volume"): + var linear_vol: float = float(params["volume"]) + AudioServer.set_bus_volume_db(bus_idx, linear_to_db(clampf(linear_vol, 0.0, 1.0))) + if params.has("mute"): + AudioServer.set_bus_mute(bus_idx, bool(params["mute"])) + if params.has("solo"): + AudioServer.set_bus_solo(bus_idx, bool(params["solo"])) + + _send_response({ + "success": true, + "bus_name": bus_name, + "volume_db": AudioServer.get_bus_volume_db(bus_idx), + "mute": AudioServer.is_bus_mute(bus_idx), + "solo": AudioServer.is_bus_solo(bus_idx) + }) + + +# --- Navigate Path --- +func _cmd_navigate_path(params: Dictionary) -> void: + var start_dict: Dictionary = params.get("start", {}) + var end_dict: Dictionary = params.get("end", {}) + var optimize: bool = params.get("optimize", true) + + if start_dict.is_empty() or end_dict.is_empty(): + _send_response({"error": "start and end are required"}) + return + + # Wait a frame to ensure navigation map is ready + await get_tree().process_frame + + var is_3d: bool = start_dict.has("z") or end_dict.has("z") + + if is_3d: + var start_pos: Vector3 = Vector3(float(start_dict.get("x", 0)), float(start_dict.get("y", 0)), float(start_dict.get("z", 0))) + var end_pos: Vector3 = Vector3(float(end_dict.get("x", 0)), float(end_dict.get("y", 0)), float(end_dict.get("z", 0))) + var map_rid: RID = get_tree().root.get_world_3d().get_navigation_map() + var path: PackedVector3Array = NavigationServer3D.map_get_path(map_rid, start_pos, end_pos, optimize) + var total_length: float = 0.0 + for i in range(1, path.size()): + total_length += path[i - 1].distance_to(path[i]) + _send_response({"success": true, "mode": "3d", "path": _variant_to_json(path), "point_count": path.size(), "total_length": total_length}) + else: + var start_pos: Vector2 = Vector2(float(start_dict.get("x", 0)), float(start_dict.get("y", 0))) + var end_pos: Vector2 = Vector2(float(end_dict.get("x", 0)), float(end_dict.get("y", 0))) + var map_rid: RID = get_tree().root.get_world_2d().get_navigation_map() + var path: PackedVector2Array = NavigationServer2D.map_get_path(map_rid, start_pos, end_pos, optimize) + var total_length: float = 0.0 + for i in range(1, path.size()): + total_length += path[i - 1].distance_to(path[i]) + _send_response({"success": true, "mode": "2d", "path": _variant_to_json(path), "point_count": path.size(), "total_length": total_length}) + + +# --- TileMap --- +func _cmd_tilemap(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var action: String = params.get("action", "get_cell") + if node_path.is_empty(): + _send_response({"error": "node_path is required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + if not node is TileMapLayer: + _send_response({"error": "Node is not a TileMapLayer: %s (is %s)" % [node_path, node.get_class()]}) + return + + var tilemap: TileMapLayer = node as TileMapLayer + + match action: + "set_cells": + var cells: Array = params.get("cells", []) + var count: int = 0 + for cell in cells: + var pos: Vector2i = Vector2i(int(cell.get("x", 0)), int(cell.get("y", 0))) + var source_id: int = int(cell.get("source_id", 0)) + var atlas_coords: Vector2i = Vector2i(int(cell.get("atlas_x", 0)), int(cell.get("atlas_y", 0))) + var alt_tile: int = int(cell.get("alt_tile", 0)) + tilemap.set_cell(pos, source_id, atlas_coords, alt_tile) + count += 1 + _send_response({"success": true, "action": "set_cells", "count": count}) + "get_cell": + var x: int = int(params.get("x", 0)) + var y: int = int(params.get("y", 0)) + var pos: Vector2i = Vector2i(x, y) + _send_response({ + "success": true, "action": "get_cell", + "x": x, "y": y, + "source_id": tilemap.get_cell_source_id(pos), + "atlas_coords": _variant_to_json(tilemap.get_cell_atlas_coords(pos)), + "alt_tile": tilemap.get_cell_alternative_tile(pos) + }) + "erase_cells": + var cells: Array = params.get("cells", []) + var count: int = 0 + for cell in cells: + tilemap.erase_cell(Vector2i(int(cell.get("x", 0)), int(cell.get("y", 0)))) + count += 1 + _send_response({"success": true, "action": "erase_cells", "count": count}) + "get_used_cells": + var source_filter: int = int(params.get("source_id", -1)) + var used: Array + if source_filter >= 0: + used = tilemap.get_used_cells_by_id(source_filter) + else: + used = tilemap.get_used_cells() + _send_response({"success": true, "action": "get_used_cells", "cells": _variant_to_json(used), "count": used.size()}) + _: + _send_response({"error": "Unknown tilemap action: %s. Use set_cells, get_cell, erase_cells, or get_used_cells" % action}) + + +# --- Add Collision Shape --- +func _cmd_add_collision(params: Dictionary) -> void: + var parent_path: String = params.get("parent_path", "") + var shape_type: String = params.get("shape_type", "") + if parent_path.is_empty() or shape_type.is_empty(): + _send_response({"error": "parent_path and shape_type are required"}) + return + + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent node not found: %s" % parent_path}) + return + + var is_3d: bool = parent.get_class().ends_with("3D") or parent is PhysicsBody3D or parent is Area3D + var shape_params: Dictionary = params.get("shape_params", {}) + var shape: Resource = null + + if is_3d: + match shape_type: + "box": + var s: BoxShape3D = BoxShape3D.new() + s.size = Vector3(float(shape_params.get("size_x", 1)), float(shape_params.get("size_y", 1)), float(shape_params.get("size_z", 1))) + shape = s + "sphere": + var s: SphereShape3D = SphereShape3D.new() + s.radius = float(shape_params.get("radius", 0.5)) + shape = s + "capsule": + var s: CapsuleShape3D = CapsuleShape3D.new() + s.radius = float(shape_params.get("radius", 0.5)) + s.height = float(shape_params.get("height", 2.0)) + shape = s + "cylinder": + var s: CylinderShape3D = CylinderShape3D.new() + s.radius = float(shape_params.get("radius", 0.5)) + s.height = float(shape_params.get("height", 2.0)) + shape = s + "ray": + var s: SeparationRayShape3D = SeparationRayShape3D.new() + s.length = float(shape_params.get("length", 1.0)) + shape = s + _: + _send_response({"error": "Unknown 3D shape type: %s. Use box, sphere, capsule, cylinder, or ray" % shape_type}) + return + var col_shape: CollisionShape3D = CollisionShape3D.new() + col_shape.shape = shape as Shape3D + if params.has("disabled"): + col_shape.disabled = bool(params["disabled"]) + parent.add_child(col_shape) + col_shape.owner = get_tree().edited_scene_root if get_tree().edited_scene_root else get_tree().root + if params.has("collision_layer"): + parent.set("collision_layer", int(params["collision_layer"])) + if params.has("collision_mask"): + parent.set("collision_mask", int(params["collision_mask"])) + _send_response({"success": true, "name": col_shape.name, "path": str(col_shape.get_path()), "shape_type": shape_type, "mode": "3d"}) + else: + match shape_type: + "box": + var s: RectangleShape2D = RectangleShape2D.new() + s.size = Vector2(float(shape_params.get("size_x", 1)), float(shape_params.get("size_y", 1))) + shape = s + "circle": + var s: CircleShape2D = CircleShape2D.new() + s.radius = float(shape_params.get("radius", 0.5)) + shape = s + "capsule": + var s: CapsuleShape2D = CapsuleShape2D.new() + s.radius = float(shape_params.get("radius", 0.5)) + s.height = float(shape_params.get("height", 2.0)) + shape = s + "segment": + var s: SegmentShape2D = SegmentShape2D.new() + s.a = Vector2(float(shape_params.get("a_x", 0)), float(shape_params.get("a_y", 0))) + s.b = Vector2(float(shape_params.get("b_x", 1)), float(shape_params.get("b_y", 0))) + shape = s + _: + _send_response({"error": "Unknown 2D shape type: %s. Use box, circle, capsule, or segment" % shape_type}) + return + var col_shape: CollisionShape2D = CollisionShape2D.new() + col_shape.shape = shape as Shape2D + if params.has("disabled"): + col_shape.disabled = bool(params["disabled"]) + parent.add_child(col_shape) + col_shape.owner = get_tree().edited_scene_root if get_tree().edited_scene_root else get_tree().root + if params.has("collision_layer"): + parent.set("collision_layer", int(params["collision_layer"])) + if params.has("collision_mask"): + parent.set("collision_mask", int(params["collision_mask"])) + _send_response({"success": true, "name": col_shape.name, "path": str(col_shape.get_path()), "shape_type": shape_type, "mode": "2d"}) + + +# --- Environment / Post-Processing --- +func _cmd_environment(params: Dictionary) -> void: + var action: String = params.get("action", "set") + + # Find existing WorldEnvironment or Camera3D environment + var env: Environment = null + var world_env: Node = null + + # Search for WorldEnvironment node + var found: Array = [] + _find_by_class_recursive(get_tree().root, "WorldEnvironment", found) + if found.size() > 0: + world_env = get_tree().root.get_node_or_null(found[0]["path"]) + if world_env != null: + env = world_env.get("environment") as Environment + + # Fallback: check Camera3D + if env == null: + var cam3d: Camera3D = get_viewport().get_camera_3d() + if cam3d != null and cam3d.get("environment") != null: + env = cam3d.get("environment") as Environment + + if action == "get": + if env == null: + _send_response({"error": "No Environment resource found"}) + return + _send_response(_get_environment_state(env)) + return + + # action == "set": create if needed + if env == null: + env = Environment.new() + var we: WorldEnvironment = WorldEnvironment.new() + we.environment = env + get_tree().root.add_child(we) + world_env = we + + # Apply settings + if params.has("background_mode"): + env.background_mode = int(params["background_mode"]) as Environment.BGMode + if params.has("background_color"): + var c: Dictionary = params["background_color"] + env.background_color = Color(float(c.get("r", 0)), float(c.get("g", 0)), float(c.get("b", 0)), float(c.get("a", 1))) + if params.has("ambient_light_color"): + var c: Dictionary = params["ambient_light_color"] + env.ambient_light_color = Color(float(c.get("r", 0)), float(c.get("g", 0)), float(c.get("b", 0)), float(c.get("a", 1))) + if params.has("ambient_light_energy"): + env.ambient_light_energy = float(params["ambient_light_energy"]) + if params.has("fog_enabled"): + env.fog_enabled = bool(params["fog_enabled"]) + if params.has("fog_density"): + env.fog_density = float(params["fog_density"]) + if params.has("fog_light_color"): + var c: Dictionary = params["fog_light_color"] + env.fog_light_color = Color(float(c.get("r", 0)), float(c.get("g", 0)), float(c.get("b", 0)), float(c.get("a", 1))) + if params.has("glow_enabled"): + env.glow_enabled = bool(params["glow_enabled"]) + if params.has("glow_intensity"): + env.glow_intensity = float(params["glow_intensity"]) + if params.has("glow_bloom"): + env.glow_bloom = float(params["glow_bloom"]) + if params.has("tonemap_mode"): + env.tonemap_mode = int(params["tonemap_mode"]) as Environment.ToneMapper + if params.has("ssao_enabled"): + env.ssao_enabled = bool(params["ssao_enabled"]) + if params.has("ssao_radius"): + env.ssao_radius = float(params["ssao_radius"]) + if params.has("ssao_intensity"): + env.ssao_intensity = float(params["ssao_intensity"]) + if params.has("ssr_enabled"): + env.ssr_enabled = bool(params["ssr_enabled"]) + if params.has("brightness"): + env.adjustment_enabled = true + env.adjustment_brightness = float(params["brightness"]) + if params.has("contrast"): + env.adjustment_enabled = true + env.adjustment_contrast = float(params["contrast"]) + if params.has("saturation"): + env.adjustment_enabled = true + env.adjustment_saturation = float(params["saturation"]) + + _send_response(_get_environment_state(env)) + + +func _get_environment_state(env: Environment) -> Dictionary: + return { + "success": true, + "background_mode": env.background_mode, + "background_color": _variant_to_json(env.background_color), + "ambient_light_color": _variant_to_json(env.ambient_light_color), + "ambient_light_energy": env.ambient_light_energy, + "fog_enabled": env.fog_enabled, + "fog_density": env.fog_density, + "fog_light_color": _variant_to_json(env.fog_light_color), + "glow_enabled": env.glow_enabled, + "glow_intensity": env.glow_intensity, + "glow_bloom": env.glow_bloom, + "tonemap_mode": env.tonemap_mode, + "ssao_enabled": env.ssao_enabled, + "ssao_radius": env.ssao_radius, + "ssao_intensity": env.ssao_intensity, + "ssr_enabled": env.ssr_enabled, + "brightness": env.adjustment_brightness, + "contrast": env.adjustment_contrast, + "saturation": env.adjustment_saturation + } + + +# --- Manage Group --- +func _cmd_manage_group(params: Dictionary) -> void: + var action: String = params.get("action", "") + var group_name: String = params.get("group", "") + + if action == "clear_group": + if group_name.is_empty(): + _send_response({"error": "group is required for clear_group"}) + return + var nodes: Array = get_tree().get_nodes_in_group(group_name) + for node in nodes: + node.remove_from_group(group_name) + _send_response({"success": true, "action": "clear_group", "group": group_name, "removed_count": nodes.size()}) + return + + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _send_response({"error": "node_path is required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + match action: + "add": + if group_name.is_empty(): + _send_response({"error": "group is required for add"}) + return + node.add_to_group(group_name) + _send_response({"success": true, "action": "add", "node_path": node_path, "group": group_name}) + "remove": + if group_name.is_empty(): + _send_response({"error": "group is required for remove"}) + return + node.remove_from_group(group_name) + _send_response({"success": true, "action": "remove", "node_path": node_path, "group": group_name}) + "get_groups": + var groups: Array = [] + for g in node.get_groups(): + groups.append(str(g)) + _send_response({"success": true, "action": "get_groups", "node_path": node_path, "groups": groups}) + _: + _send_response({"error": "Unknown group action: %s. Use add, remove, get_groups, or clear_group" % action}) + + +# --- Create Timer --- +func _cmd_create_timer(params: Dictionary) -> void: + var parent_path: String = params.get("parent_path", "/root") + var wait_time: float = float(params.get("wait_time", 1.0)) + var one_shot: bool = params.get("one_shot", false) + var autostart: bool = params.get("autostart", false) + + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent node not found: %s" % parent_path}) + return + + var timer: Timer = Timer.new() + timer.wait_time = wait_time + timer.one_shot = one_shot + timer.autostart = autostart + if params.has("name") and params["name"] is String and not (params["name"] as String).is_empty(): + timer.name = params["name"] + parent.add_child(timer) + if autostart: + timer.start() + _send_response({"success": true, "path": str(timer.get_path()), "name": timer.name, "wait_time": timer.wait_time, "one_shot": timer.one_shot, "autostart": autostart}) + + +# --- Set Particles --- +func _cmd_set_particles(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _send_response({"error": "node_path is required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + if not (node is GPUParticles2D or node is GPUParticles3D): + _send_response({"error": "Node is not a GPUParticles node: %s (is %s)" % [node_path, node.get_class()]}) + return + + # Set direct particle properties + if params.has("emitting"): + node.set("emitting", bool(params["emitting"])) + if params.has("amount"): + node.set("amount", int(params["amount"])) + if params.has("lifetime"): + node.set("lifetime", float(params["lifetime"])) + if params.has("one_shot"): + node.set("one_shot", bool(params["one_shot"])) + if params.has("speed_scale"): + node.set("speed_scale", float(params["speed_scale"])) + if params.has("explosiveness"): + node.set("explosiveness", float(params["explosiveness"])) + if params.has("randomness"): + node.set("randomness", float(params["randomness"])) + + # Configure process material + if params.has("process_material"): + var mat_params: Dictionary = params["process_material"] + var mat: ParticleProcessMaterial = node.get("process_material") as ParticleProcessMaterial + if mat == null: + mat = ParticleProcessMaterial.new() + node.set("process_material", mat) + if mat_params.has("direction"): + var d: Dictionary = mat_params["direction"] + mat.direction = Vector3(float(d.get("x", 0)), float(d.get("y", -1)), float(d.get("z", 0))) + if mat_params.has("spread"): + mat.spread = float(mat_params["spread"]) + if mat_params.has("gravity"): + var g: Dictionary = mat_params["gravity"] + mat.gravity = Vector3(float(g.get("x", 0)), float(g.get("y", -9.8)), float(g.get("z", 0))) + if mat_params.has("initial_velocity_min"): + mat.initial_velocity_min = float(mat_params["initial_velocity_min"]) + if mat_params.has("initial_velocity_max"): + mat.initial_velocity_max = float(mat_params["initial_velocity_max"]) + if mat_params.has("color"): + var c: Dictionary = mat_params["color"] + mat.color = Color(float(c.get("r", 1)), float(c.get("g", 1)), float(c.get("b", 1)), float(c.get("a", 1))) + if mat_params.has("scale_min"): + mat.scale_min = float(mat_params["scale_min"]) + if mat_params.has("scale_max"): + mat.scale_max = float(mat_params["scale_max"]) + + _send_response({ + "success": true, "node_path": node_path, + "emitting": node.get("emitting"), "amount": node.get("amount"), + "lifetime": node.get("lifetime"), "one_shot": node.get("one_shot"), + "speed_scale": node.get("speed_scale") + }) + + +# --- Create Animation --- +func _cmd_create_animation(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var anim_name: String = params.get("animation_name", "") + if node_path.is_empty() or anim_name.is_empty(): + _send_response({"error": "node_path and animation_name are required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + if not node is AnimationPlayer: + _send_response({"error": "Node is not an AnimationPlayer: %s (is %s)" % [node_path, node.get_class()]}) + return + + var anim_player: AnimationPlayer = node as AnimationPlayer + var anim: Animation = Animation.new() + anim.length = float(params.get("length", 1.0)) + var loop_mode: int = int(params.get("loop_mode", 0)) + anim.loop_mode = loop_mode as Animation.LoopMode + + var tracks: Array = params.get("tracks", []) + var track_count: int = 0 + for track_data in tracks: + var track_type_str: String = track_data.get("type", "value") + var track_path: String = track_data.get("path", "") + if track_path.is_empty(): + continue + + var track_type: int = Animation.TYPE_VALUE + match track_type_str: + "value": + track_type = Animation.TYPE_VALUE + "method": + track_type = Animation.TYPE_METHOD + "bezier": + track_type = Animation.TYPE_BEZIER + "audio": + track_type = Animation.TYPE_AUDIO + + var idx: int = anim.add_track(track_type) + anim.track_set_path(idx, NodePath(track_path)) + + var keys: Array = track_data.get("keys", []) + for key_data in keys: + var time: float = float(key_data.get("time", 0.0)) + match track_type: + Animation.TYPE_VALUE: + var value: Variant = _json_to_variant(key_data.get("value", null), key_data.get("type_hint", "")) + anim.track_insert_key(idx, time, value) + if key_data.has("transition"): + var key_idx: int = anim.track_find_key(idx, time, Animation.FIND_MODE_APPROX) + if key_idx >= 0: + anim.track_set_key_transition(idx, key_idx, float(key_data["transition"])) + Animation.TYPE_METHOD: + var method_name: String = key_data.get("method", "") + var args: Array = key_data.get("args", []) + anim.track_insert_key(idx, time, {"method": method_name, "args": args}) + Animation.TYPE_BEZIER: + var value: float = float(key_data.get("value", 0.0)) + anim.bezier_track_insert_key(idx, time, value) + Animation.TYPE_AUDIO: + var stream_path: String = key_data.get("stream", "") + if not stream_path.is_empty(): + var stream: AudioStream = load(stream_path) as AudioStream + if stream != null: + anim.audio_track_insert_key(idx, time, stream) + track_count += 1 + + # Add to library (use default "" library if it exists, otherwise create it) + var lib_name: String = params.get("library", "") + var lib: AnimationLibrary = null + if anim_player.has_animation_library(lib_name): + lib = anim_player.get_animation_library(lib_name) + else: + lib = AnimationLibrary.new() + anim_player.add_animation_library(lib_name, lib) + lib.add_animation(anim_name, anim) + + _send_response({"success": true, "animation_name": anim_name, "length": anim.length, "loop_mode": loop_mode, "track_count": track_count}) + + +# --- Serialize State --- +func _cmd_serialize_state(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "/root") + var action: String = params.get("action", "save") + var max_depth: int = int(params.get("max_depth", 5)) + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + match action: + "save": + var state: Dictionary = _serialize_node(node, max_depth, 0) + _send_response({"success": true, "action": "save", "state": state}) + "load": + var data: Dictionary = params.get("data", {}) + if data.is_empty(): + _send_response({"error": "data is required for load action"}) + return + var count: int = _deserialize_node(node, data) + _send_response({"success": true, "action": "load", "restored_count": count}) + _: + _send_response({"error": "Unknown serialize action: %s. Use save or load" % action}) + + +func _serialize_node(node: Node, max_depth: int, depth: int) -> Dictionary: + var result: Dictionary = { + "class": node.get_class(), + "name": node.name, + "path": str(node.get_path()), + } + # Capture editor-visible properties + var props: Dictionary = {} + for prop in node.get_property_list(): + var prop_dict: Dictionary = prop + if prop_dict.get("usage", 0) & PROPERTY_USAGE_STORAGE: + var prop_name: String = prop_dict.get("name", "") + if prop_name.is_empty() or prop_name.begins_with("_"): + continue + props[prop_name] = _variant_to_json(node.get(prop_name)) + result["properties"] = props + + if depth < max_depth: + var children: Array = [] + for child in node.get_children(): + # Skip the MCP interaction server itself + if child == self: + continue + children.append(_serialize_node(child, max_depth, depth + 1)) + result["children"] = children + + return result + + +func _deserialize_node(node: Node, data: Dictionary) -> int: + var count: int = 0 + # Restore properties + var props: Dictionary = data.get("properties", {}) + for prop_name in props: + var value: Variant = _json_to_variant_for_property(node, prop_name, props[prop_name]) + node.set(prop_name, value) + count += 1 + + # Restore children + var children_data: Array = data.get("children", []) + for child_data in children_data: + var child_name: String = child_data.get("name", "") + var child: Node = null + for c in node.get_children(): + if c.name == child_name: + child = c + break + if child != null: + count += _deserialize_node(child, child_data) + return count + + +# --- Physics Body --- +func _cmd_physics_body(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _send_response({"error": "node_path is required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + if not (node is PhysicsBody2D or node is PhysicsBody3D): + _send_response({"error": "Node is not a PhysicsBody: %s (is %s)" % [node_path, node.get_class()]}) + return + + # Set common physics properties + if params.has("gravity_scale") and node.get("gravity_scale") != null: + node.set("gravity_scale", float(params["gravity_scale"])) + if params.has("mass") and node.get("mass") != null: + node.set("mass", float(params["mass"])) + if params.has("freeze") and node.get("freeze") != null: + node.set("freeze", bool(params["freeze"])) + if params.has("sleeping") and node.get("sleeping") != null: + node.set("sleeping", bool(params["sleeping"])) + if params.has("linear_damp") and node.get("linear_damp") != null: + node.set("linear_damp", float(params["linear_damp"])) + if params.has("angular_damp") and node.get("angular_damp") != null: + node.set("angular_damp", float(params["angular_damp"])) + + # Velocity (2D vs 3D) + if params.has("linear_velocity"): + var lv: Dictionary = params["linear_velocity"] + if node is PhysicsBody3D: + node.set("linear_velocity", Vector3(float(lv.get("x", 0)), float(lv.get("y", 0)), float(lv.get("z", 0)))) + else: + node.set("linear_velocity", Vector2(float(lv.get("x", 0)), float(lv.get("y", 0)))) + if params.has("angular_velocity"): + var av: Variant = params["angular_velocity"] + if node is PhysicsBody3D and av is Dictionary: + node.set("angular_velocity", Vector3(float(av.get("x", 0)), float(av.get("y", 0)), float(av.get("z", 0)))) + else: + node.set("angular_velocity", float(av)) + + # Physics material (friction, bounce) + if params.has("friction") or params.has("bounce"): + var phys_mat: PhysicsMaterial = node.get("physics_material_override") as PhysicsMaterial + if phys_mat == null: + phys_mat = PhysicsMaterial.new() + node.set("physics_material_override", phys_mat) + if params.has("friction"): + phys_mat.friction = float(params["friction"]) + if params.has("bounce"): + phys_mat.bounce = float(params["bounce"]) + + # Build response + var result: Dictionary = {"success": true, "node_path": node_path, "class": node.get_class()} + if node.get("mass") != null: + result["mass"] = node.get("mass") + if node.get("gravity_scale") != null: + result["gravity_scale"] = node.get("gravity_scale") + if node.get("linear_velocity") != null: + result["linear_velocity"] = _variant_to_json(node.get("linear_velocity")) + if node.get("angular_velocity") != null: + result["angular_velocity"] = _variant_to_json(node.get("angular_velocity")) + _send_response(result) + + +# --- Create Joint --- +func _cmd_create_joint(params: Dictionary) -> void: + var parent_path: String = params.get("parent_path", "") + var joint_type: String = params.get("joint_type", "") + if parent_path.is_empty() or joint_type.is_empty(): + _send_response({"error": "parent_path and joint_type are required"}) + return + + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent node not found: %s" % parent_path}) + return + + var node_a: String = params.get("node_a_path", "") + var node_b: String = params.get("node_b_path", "") + var joint: Node = null + + match joint_type: + "pin_2d": + var j: PinJoint2D = PinJoint2D.new() + if not node_a.is_empty(): + j.node_a = NodePath(node_a) + if not node_b.is_empty(): + j.node_b = NodePath(node_b) + if params.has("softness"): + j.softness = float(params["softness"]) + joint = j + "spring_2d": + var j: DampedSpringJoint2D = DampedSpringJoint2D.new() + if not node_a.is_empty(): + j.node_a = NodePath(node_a) + if not node_b.is_empty(): + j.node_b = NodePath(node_b) + if params.has("length"): + j.length = float(params["length"]) + if params.has("rest_length"): + j.rest_length = float(params["rest_length"]) + if params.has("stiffness"): + j.stiffness = float(params["stiffness"]) + if params.has("damping"): + j.damping = float(params["damping"]) + joint = j + "groove_2d": + var j: GrooveJoint2D = GrooveJoint2D.new() + if not node_a.is_empty(): + j.node_a = NodePath(node_a) + if not node_b.is_empty(): + j.node_b = NodePath(node_b) + if params.has("length"): + j.length = float(params["length"]) + if params.has("initial_offset"): + j.initial_offset = float(params["initial_offset"]) + joint = j + "pin_3d": + var j: PinJoint3D = PinJoint3D.new() + if not node_a.is_empty(): + j.node_a = NodePath(node_a) + if not node_b.is_empty(): + j.node_b = NodePath(node_b) + joint = j + "hinge_3d": + var j: HingeJoint3D = HingeJoint3D.new() + if not node_a.is_empty(): + j.node_a = NodePath(node_a) + if not node_b.is_empty(): + j.node_b = NodePath(node_b) + joint = j + "cone_3d": + var j: ConeTwistJoint3D = ConeTwistJoint3D.new() + if not node_a.is_empty(): + j.node_a = NodePath(node_a) + if not node_b.is_empty(): + j.node_b = NodePath(node_b) + joint = j + "slider_3d": + var j: SliderJoint3D = SliderJoint3D.new() + if not node_a.is_empty(): + j.node_a = NodePath(node_a) + if not node_b.is_empty(): + j.node_b = NodePath(node_b) + joint = j + _: + _send_response({"error": "Unknown joint type: %s. Use pin_2d, spring_2d, groove_2d, pin_3d, hinge_3d, cone_3d, or slider_3d" % joint_type}) + return + + parent.add_child(joint) + _send_response({"success": true, "joint_type": joint_type, "name": joint.name, "path": str(joint.get_path())}) + + +# --- Bone Pose --- +func _cmd_bone_pose(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var action: String = params.get("action", "list") + if node_path.is_empty(): + _send_response({"error": "node_path is required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + if not node is Skeleton3D: + _send_response({"error": "Node is not a Skeleton3D: %s (is %s)" % [node_path, node.get_class()]}) + return + + var skel: Skeleton3D = node as Skeleton3D + + match action: + "list": + var bones: Array = [] + for i in skel.get_bone_count(): + bones.append({"index": i, "name": skel.get_bone_name(i), "parent": skel.get_bone_parent(i)}) + _send_response({"success": true, "action": "list", "bone_count": skel.get_bone_count(), "bones": bones}) + "get": + var bone_idx: int = _resolve_bone_index(skel, params) + if bone_idx < 0: + _send_response({"error": "Bone not found"}) + return + _send_response({ + "success": true, "action": "get", "bone_index": bone_idx, + "bone_name": skel.get_bone_name(bone_idx), + "position": _variant_to_json(skel.get_bone_pose_position(bone_idx)), + "rotation": _variant_to_json(skel.get_bone_pose_rotation(bone_idx)), + "scale": _variant_to_json(skel.get_bone_pose_scale(bone_idx)) + }) + "set": + var bone_idx: int = _resolve_bone_index(skel, params) + if bone_idx < 0: + _send_response({"error": "Bone not found"}) + return + if params.has("position"): + var p: Dictionary = params["position"] + skel.set_bone_pose_position(bone_idx, Vector3(float(p.get("x", 0)), float(p.get("y", 0)), float(p.get("z", 0)))) + if params.has("rotation"): + var r: Dictionary = params["rotation"] + skel.set_bone_pose_rotation(bone_idx, Quaternion(float(r.get("x", 0)), float(r.get("y", 0)), float(r.get("z", 0)), float(r.get("w", 1)))) + if params.has("scale"): + var s: Dictionary = params["scale"] + skel.set_bone_pose_scale(bone_idx, Vector3(float(s.get("x", 1)), float(s.get("y", 1)), float(s.get("z", 1)))) + _send_response({"success": true, "action": "set", "bone_index": bone_idx, "bone_name": skel.get_bone_name(bone_idx)}) + _: + _send_response({"error": "Unknown bone action: %s. Use list, get, or set" % action}) + + +func _resolve_bone_index(skel: Skeleton3D, params: Dictionary) -> int: + if params.has("bone_index"): + return int(params["bone_index"]) + if params.has("bone_name"): + return skel.find_bone(params["bone_name"]) + return -1 + + +# --- UI Theme --- +func _cmd_ui_theme(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _send_response({"error": "node_path is required"}) + return + + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + + if not node is Control: + _send_response({"error": "Node is not a Control: %s (is %s)" % [node_path, node.get_class()]}) + return + + var ctrl: Control = node as Control + var overrides: Dictionary = params.get("overrides", {}) + var applied: Array = [] + + # Color overrides + var colors: Dictionary = overrides.get("colors", {}) + for name in colors: + var c: Dictionary = colors[name] + ctrl.add_theme_color_override(name, Color(float(c.get("r", 0)), float(c.get("g", 0)), float(c.get("b", 0)), float(c.get("a", 1)))) + applied.append("color:" + name) + + # Constant overrides + var constants: Dictionary = overrides.get("constants", {}) + for name in constants: + ctrl.add_theme_constant_override(name, int(constants[name])) + applied.append("constant:" + name) + + # Font size overrides + var font_sizes: Dictionary = overrides.get("font_sizes", {}) + for name in font_sizes: + ctrl.add_theme_font_size_override(name, int(font_sizes[name])) + applied.append("font_size:" + name) + + _send_response({"success": true, "node_path": node_path, "applied": applied}) + + +# --- Viewport --- +func _cmd_viewport(params: Dictionary) -> void: + var action: String = params.get("action", "create") + + match action: + "create": + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent node not found: %s" % parent_path}) + return + var viewport: SubViewport = SubViewport.new() + if params.has("width") and params.has("height"): + viewport.size = Vector2i(int(params["width"]), int(params["height"])) + if params.has("transparent_bg"): + viewport.transparent_bg = bool(params["transparent_bg"]) + if params.has("msaa"): + viewport.msaa_2d = int(params["msaa"]) as Viewport.MSAA + viewport.msaa_3d = int(params["msaa"]) as Viewport.MSAA + if params.has("name") and params["name"] is String and not (params["name"] as String).is_empty(): + viewport.name = params["name"] + var container: SubViewportContainer = SubViewportContainer.new() + container.add_child(viewport) + parent.add_child(container) + _send_response({"success": true, "action": "create", "viewport_path": str(viewport.get_path()), "container_path": str(container.get_path()), "size": _variant_to_json(viewport.size)}) + "configure": + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _send_response({"error": "node_path is required for configure"}) + return + var vp: Node = get_tree().root.get_node_or_null(node_path) + if vp == null or not vp is SubViewport: + _send_response({"error": "SubViewport not found: %s" % node_path}) + return + var sv: SubViewport = vp as SubViewport + if params.has("width") and params.has("height"): + sv.size = Vector2i(int(params["width"]), int(params["height"])) + if params.has("transparent_bg"): + sv.transparent_bg = bool(params["transparent_bg"]) + if params.has("msaa"): + sv.msaa_2d = int(params["msaa"]) as Viewport.MSAA + sv.msaa_3d = int(params["msaa"]) as Viewport.MSAA + _send_response({"success": true, "action": "configure", "size": _variant_to_json(sv.size), "transparent_bg": sv.transparent_bg}) + "get": + var node_path: String = params.get("node_path", "") + if node_path.is_empty(): + _send_response({"error": "node_path is required for get"}) + return + var vp: Node = get_tree().root.get_node_or_null(node_path) + if vp == null or not vp is SubViewport: + _send_response({"error": "SubViewport not found: %s" % node_path}) + return + var sv: SubViewport = vp as SubViewport + _send_response({"success": true, "action": "get", "size": _variant_to_json(sv.size), "transparent_bg": sv.transparent_bg, "msaa_2d": sv.msaa_2d, "msaa_3d": sv.msaa_3d}) + _: + _send_response({"error": "Unknown viewport action: %s. Use create, configure, or get" % action}) + + +# --- Debug Draw --- +var _debug_draw_node: Node = null +var _debug_meshes: Array = [] + +func _cmd_debug_draw(params: Dictionary) -> void: + var action: String = params.get("action", "line") + var color_dict: Dictionary = params.get("color", {"r": 1.0, "g": 0.0, "b": 0.0}) + var color: Color = Color(float(color_dict.get("r", 1)), float(color_dict.get("g", 0)), float(color_dict.get("b", 0)), float(color_dict.get("a", 1))) + var duration: int = int(params.get("duration", 0)) + + if action == "clear": + _clear_debug_draw() + _send_response({"success": true, "action": "clear"}) + return + + # Ensure we have a debug draw parent + if _debug_draw_node == null or not is_instance_valid(_debug_draw_node): + _debug_draw_node = Node3D.new() + _debug_draw_node.name = "_McpDebugDraw" + get_tree().root.add_child(_debug_draw_node) + + var mat: StandardMaterial3D = StandardMaterial3D.new() + mat.albedo_color = color + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + mat.no_depth_test = true + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA if color.a < 1.0 else BaseMaterial3D.TRANSPARENCY_DISABLED + + match action: + "line": + var from_dict: Dictionary = params.get("from", {}) + var to_dict: Dictionary = params.get("to", {}) + var from_pos: Vector3 = Vector3(float(from_dict.get("x", 0)), float(from_dict.get("y", 0)), float(from_dict.get("z", 0))) + var to_pos: Vector3 = Vector3(float(to_dict.get("x", 0)), float(to_dict.get("y", 0)), float(to_dict.get("z", 0))) + var im: ImmediateMesh = ImmediateMesh.new() + im.surface_begin(Mesh.PRIMITIVE_LINES, mat) + im.surface_add_vertex(from_pos) + im.surface_add_vertex(to_pos) + im.surface_end() + var mi: MeshInstance3D = MeshInstance3D.new() + mi.mesh = im + _debug_draw_node.add_child(mi) + _debug_meshes.append({"node": mi, "frames_left": duration}) + _send_response({"success": true, "action": "line"}) + "sphere": + var center_dict: Dictionary = params.get("center", {}) + var center: Vector3 = Vector3(float(center_dict.get("x", 0)), float(center_dict.get("y", 0)), float(center_dict.get("z", 0))) + var radius: float = float(params.get("radius", 0.5)) + var sphere_mesh: SphereMesh = SphereMesh.new() + sphere_mesh.radius = radius + sphere_mesh.height = radius * 2.0 + sphere_mesh.material = mat + var mi: MeshInstance3D = MeshInstance3D.new() + mi.mesh = sphere_mesh + mi.global_position = center + _debug_draw_node.add_child(mi) + _debug_meshes.append({"node": mi, "frames_left": duration}) + _send_response({"success": true, "action": "sphere"}) + "box": + var center_dict: Dictionary = params.get("center", {}) + var center: Vector3 = Vector3(float(center_dict.get("x", 0)), float(center_dict.get("y", 0)), float(center_dict.get("z", 0))) + var size_dict: Dictionary = params.get("size", {"x": 1, "y": 1, "z": 1}) + var box_size: Vector3 = Vector3(float(size_dict.get("x", 1)), float(size_dict.get("y", 1)), float(size_dict.get("z", 1))) + var box_mesh: BoxMesh = BoxMesh.new() + box_mesh.size = box_size + box_mesh.material = mat + var mi: MeshInstance3D = MeshInstance3D.new() + mi.mesh = box_mesh + mi.global_position = center + _debug_draw_node.add_child(mi) + _debug_meshes.append({"node": mi, "frames_left": duration}) + _send_response({"success": true, "action": "box"}) + _: + _send_response({"error": "Unknown debug draw action: %s. Use line, sphere, box, or clear" % action}) + + +func _clear_debug_draw() -> void: + for entry in _debug_meshes: + if is_instance_valid(entry["node"]): + entry["node"].queue_free() + _debug_meshes.clear() + if _debug_draw_node != null and is_instance_valid(_debug_draw_node): + _debug_draw_node.queue_free() + _debug_draw_node = null + + +# ========================================================================== +# Batch 1: Networking + Input + System + Signals + Script +# ========================================================================== + +func _cmd_http_request(params: Dictionary) -> void: + var url: String = params.get("url", "") + if url.is_empty(): + _send_response({"error": "url is required"}) + return + var method_str: String = params.get("method", "GET").to_upper() + var http: HTTPRequest = HTTPRequest.new() + http.timeout = float(params.get("timeout", 30)) + add_child(http) + var headers: PackedStringArray = PackedStringArray() + if params.has("headers"): + var h: Dictionary = params["headers"] + for k in h: + headers.append("%s: %s" % [k, str(h[k])]) + var method_enum: int = HTTPClient.METHOD_GET + match method_str: + "POST": method_enum = HTTPClient.METHOD_POST + "PUT": method_enum = HTTPClient.METHOD_PUT + "DELETE": method_enum = HTTPClient.METHOD_DELETE + var body: String = params.get("body", "") + var err: int = http.request(url, headers, method_enum, body) + if err != OK: + http.queue_free() + _send_response({"error": "HTTP request failed to start: %d" % err}) + return + var result: Array = await http.request_completed + http.queue_free() + _send_response({"success": true, "status_code": result[1], "body": result[3].get_string_from_utf8()}) + + +var _websocket: WebSocketPeer = null + +func _cmd_websocket(params: Dictionary) -> void: + var action: String = params.get("action", "") + match action: + "connect": + var url: String = params.get("url", "") + if url.is_empty(): + _send_response({"error": "url is required for connect"}) + return + _websocket = WebSocketPeer.new() + var err: int = _websocket.connect_to_url(url) + if err != OK: + _send_response({"error": "WebSocket connect failed: %d" % err}) + _websocket = null + return + _send_response({"success": true, "action": "connect", "url": url}) + "disconnect": + if _websocket != null: + _websocket.close() + _websocket = null + _send_response({"success": true, "action": "disconnect"}) + "send": + if _websocket == null: + _send_response({"error": "No WebSocket connection"}) + return + _websocket.poll() + var msg: String = params.get("message", "") + _websocket.send_text(msg) + _send_response({"success": true, "action": "send"}) + "status": + if _websocket == null: + _send_response({"success": true, "status": "disconnected"}) + return + _websocket.poll() + _send_response({"success": true, "status": _websocket.get_ready_state()}) + _: + _send_response({"error": "Unknown websocket action: %s" % action}) + + +func _cmd_multiplayer(params: Dictionary) -> void: + var action: String = params.get("action", "") + match action: + "create_server": + var peer: ENetMultiplayerPeer = ENetMultiplayerPeer.new() + var port: int = int(params.get("port", 7000)) + var max_cl: int = int(params.get("max_clients", 32)) + var err: int = peer.create_server(port, max_cl) + if err != OK: + _send_response({"error": "Failed to create server: %d" % err}) + return + multiplayer.multiplayer_peer = peer + _send_response({"success": true, "action": "create_server", "port": port}) + "create_client": + var peer: ENetMultiplayerPeer = ENetMultiplayerPeer.new() + var address: String = params.get("address", "127.0.0.1") + var port: int = int(params.get("port", 7000)) + var err: int = peer.create_client(address, port) + if err != OK: + _send_response({"error": "Failed to create client: %d" % err}) + return + multiplayer.multiplayer_peer = peer + _send_response({"success": true, "action": "create_client", "address": address, "port": port}) + "disconnect": + multiplayer.multiplayer_peer = null + _send_response({"success": true, "action": "disconnect"}) + "status": + var peer = multiplayer.multiplayer_peer + if peer == null: + _send_response({"success": true, "connected": false}) + return + _send_response({"success": true, "connected": true, "unique_id": multiplayer.get_unique_id(), "is_server": multiplayer.is_server()}) + _: + _send_response({"error": "Unknown multiplayer action: %s" % action}) + + +func _cmd_rpc(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + var action: String = params.get("action", "call") + var method: String = params.get("method", "") + if action == "call": + var args: Array = params.get("args", []) + node.rpc(method, args) + _send_response({"success": true, "action": "call", "method": method}) + else: + _send_response({"success": true, "action": action, "method": method}) + + +func _cmd_touch(params: Dictionary) -> void: + var action: String = params.get("action", "press") + var x: float = float(params.get("x", 0)) + var y: float = float(params.get("y", 0)) + var idx: int = int(params.get("index", 0)) + match action: + "press": + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.index = idx + ev.position = Vector2(x, y) + ev.pressed = true + Input.parse_input_event(ev) + await get_tree().process_frame + _send_response({"success": true, "action": "press", "x": x, "y": y}) + "release": + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.index = idx + ev.position = Vector2(x, y) + ev.pressed = false + Input.parse_input_event(ev) + await get_tree().process_frame + _send_response({"success": true, "action": "release", "x": x, "y": y}) + "drag": + var to_x: float = float(params.get("to_x", x)) + var to_y: float = float(params.get("to_y", y)) + var steps: int = int(params.get("steps", 10)) + var press_ev: InputEventScreenTouch = InputEventScreenTouch.new() + press_ev.index = idx + press_ev.position = Vector2(x, y) + press_ev.pressed = true + Input.parse_input_event(press_ev) + for i in range(steps): + var t: float = float(i + 1) / float(steps) + var drag_ev: InputEventScreenDrag = InputEventScreenDrag.new() + drag_ev.index = idx + drag_ev.position = Vector2(lerp(x, to_x, t), lerp(y, to_y, t)) + Input.parse_input_event(drag_ev) + await get_tree().process_frame + var rel_ev: InputEventScreenTouch = InputEventScreenTouch.new() + rel_ev.index = idx + rel_ev.position = Vector2(to_x, to_y) + rel_ev.pressed = false + Input.parse_input_event(rel_ev) + await get_tree().process_frame + _send_response({"success": true, "action": "drag", "from": {"x": x, "y": y}, "to": {"x": to_x, "y": to_y}}) + _: + _send_response({"error": "Unknown touch action: %s" % action}) + + +func _cmd_input_state(params: Dictionary) -> void: + var action: String = params.get("action", "query") + match action: + "query": + var mouse_pos: Vector2 = get_viewport().get_mouse_position() + var joypads: Array = Input.get_connected_joypads() + _send_response({"success": true, "mouse_position": {"x": mouse_pos.x, "y": mouse_pos.y}, "connected_joypads": joypads.size()}) + "warp_mouse": + var pos: Vector2 = Vector2(float(params.get("x", 0)), float(params.get("y", 0))) + Input.warp_mouse(pos) + _send_response({"success": true, "action": "warp_mouse", "position": {"x": pos.x, "y": pos.y}}) + "set_mouse_mode": + var mode_str: String = params.get("mouse_mode", "visible") + var mode_val: int = Input.MOUSE_MODE_VISIBLE + match mode_str: + "hidden": mode_val = Input.MOUSE_MODE_HIDDEN + "captured": mode_val = Input.MOUSE_MODE_CAPTURED + "confined": mode_val = Input.MOUSE_MODE_CONFINED + Input.mouse_mode = mode_val + _send_response({"success": true, "action": "set_mouse_mode", "mode": mode_str}) + _: + _send_response({"error": "Unknown input_state action: %s" % action}) + + +func _cmd_input_action(params: Dictionary) -> void: + var action: String = params.get("action", "") + match action: + "set_strength": + var action_name: String = params.get("action_name", "") + var strength: float = float(params.get("strength", 1.0)) + Input.action_press(action_name, strength) + _send_response({"success": true, "action": "set_strength", "action_name": action_name, "strength": strength}) + "add_action": + var action_name: String = params.get("action_name", "") + if not InputMap.has_action(action_name): + InputMap.add_action(action_name) + if params.has("key"): + var ev: InputEventKey = InputEventKey.new() + ev.keycode = OS.find_keycode_from_string(params["key"]) + InputMap.action_add_event(action_name, ev) + _send_response({"success": true, "action": "add_action", "action_name": action_name}) + "remove_action": + var action_name: String = params.get("action_name", "") + if InputMap.has_action(action_name): + InputMap.erase_action(action_name) + _send_response({"success": true, "action": "remove_action", "action_name": action_name}) + "list": + var actions: Array = InputMap.get_actions() + _send_response({"success": true, "actions": actions}) + _: + _send_response({"error": "Unknown input_action action: %s" % action}) + + +func _cmd_list_signals(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + var signals: Array = [] + for sig in node.get_signal_list(): + var connections: Array = [] + for conn in node.get_signal_connection_list(sig["name"]): + connections.append({"callable": str(conn["callable"]), "flags": conn["flags"]}) + signals.append({"name": sig["name"], "args": str(sig["args"]), "connections": connections}) + _send_response({"success": true, "node_path": node_path, "signals": signals}) + + +func _cmd_await_signal(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var signal_name: String = params.get("signal_name", "") + var timeout: float = float(params.get("timeout", 10)) + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + if not node.has_signal(signal_name): + _send_response({"error": "Signal not found: %s on %s" % [signal_name, node_path]}) + return + var timer: SceneTreeTimer = get_tree().create_timer(timeout) + var result: Array = [false, []] + var cb: Callable = func(): + result[0] = true + node.connect(signal_name, cb, CONNECT_ONE_SHOT) + while not result[0] and timer.time_left > 0: + await get_tree().process_frame + if node.is_connected(signal_name, cb): + node.disconnect(signal_name, cb) + if result[0]: + _send_response({"success": true, "signal_name": signal_name, "received": true}) + else: + _send_response({"success": true, "signal_name": signal_name, "received": false, "timeout": true}) + + +func _cmd_script(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + var action: String = params.get("action", "get_source") + match action: + "get_source": + var s = node.get_script() + if s == null: + _send_response({"success": true, "has_script": false}) + return + _send_response({"success": true, "has_script": true, "source": s.source_code if s is GDScript else "", "path": s.resource_path}) + "attach": + var source: String = params.get("source", "") + if source.is_empty(): + _send_response({"error": "source is required for attach"}) + return + var s: GDScript = GDScript.new() + s.source_code = source + var err: int = s.reload() + if err != OK: + _send_response({"error": "Script compile error: %d" % err}) + return + node.set_script(s) + _send_response({"success": true, "action": "attach", "node_path": node_path}) + "detach": + node.set_script(null) + _send_response({"success": true, "action": "detach", "node_path": node_path}) + _: + _send_response({"error": "Unknown script action: %s" % action}) + + +func _cmd_window(params: Dictionary) -> void: + var action: String = params.get("action", "get") + var win: Window = get_tree().root + if action == "get": + _send_response({"success": true, "size": {"x": win.size.x, "y": win.size.y}, "position": {"x": win.position.x, "y": win.position.y}, "fullscreen": win.mode == Window.MODE_FULLSCREEN, "borderless": win.borderless, "title": win.title}) + return + if params.has("width") and params.has("height"): + win.size = Vector2i(int(params["width"]), int(params["height"])) + if params.has("fullscreen"): + win.mode = Window.MODE_FULLSCREEN if bool(params["fullscreen"]) else Window.MODE_WINDOWED + if params.has("borderless"): + win.borderless = bool(params["borderless"]) + if params.has("title"): + win.title = str(params["title"]) + if params.has("position"): + var p: Dictionary = params["position"] + win.position = Vector2i(int(p.get("x", 0)), int(p.get("y", 0))) + if params.has("vsync"): + DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ENABLED if bool(params["vsync"]) else DisplayServer.VSYNC_DISABLED) + _send_response({"success": true, "action": "set", "size": {"x": win.size.x, "y": win.size.y}}) + + +func _cmd_os_info() -> void: + var screen_size: Vector2i = DisplayServer.screen_get_size() + _send_response({"success": true, "os_name": OS.get_name(), "locale": OS.get_locale(), "screen_size": {"x": screen_size.x, "y": screen_size.y}, "video_adapter": RenderingServer.get_video_adapter_name(), "processor_count": OS.get_processor_count()}) + + +func _cmd_time_scale(params: Dictionary) -> void: + var action: String = params.get("action", "get") + if action == "set": + Engine.time_scale = float(params.get("time_scale", 1.0)) + _send_response({"success": true, "time_scale": Engine.time_scale, "ticks_msec": Time.get_ticks_msec(), "fps": Engine.get_frames_per_second()}) + + +func _cmd_process_mode(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + var mode_str: String = params.get("mode", "inherit") + var mode_val: int = Node.PROCESS_MODE_INHERIT + match mode_str: + "pausable": mode_val = Node.PROCESS_MODE_PAUSABLE + "when_paused": mode_val = Node.PROCESS_MODE_WHEN_PAUSED + "always": mode_val = Node.PROCESS_MODE_ALWAYS + "disabled": mode_val = Node.PROCESS_MODE_DISABLED + node.process_mode = mode_val + _send_response({"success": true, "node_path": node_path, "mode": mode_str}) + + +func _cmd_world_settings(params: Dictionary) -> void: + var action: String = params.get("action", "get") + if action == "set": + if params.has("gravity"): + ProjectSettings.set_setting("physics/3d/default_gravity", float(params["gravity"])) + if params.has("physics_fps"): + Engine.physics_ticks_per_second = int(params["physics_fps"]) + _send_response({"success": true, "gravity": ProjectSettings.get_setting("physics/3d/default_gravity"), "physics_fps": Engine.physics_ticks_per_second}) + + +# ========================================================================== +# Batch 2: 3D Rendering + Lighting + Sky + Physics +# ========================================================================== + +func _cmd_csg(params: Dictionary) -> void: + var action: String = params.get("action", "create") + if action == "create": + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var csg_type: String = params.get("csg_type", "box") + var node: CSGShape3D + match csg_type: + "box": node = CSGBox3D.new() + "sphere": node = CSGSphere3D.new() + "cylinder": node = CSGCylinder3D.new() + "mesh": node = CSGMesh3D.new() + "combiner": node = CSGCombiner3D.new() + _: + _send_response({"error": "Unknown CSG type: %s" % csg_type}) + return + if params.has("operation"): + match params["operation"]: + "union": node.operation = CSGShape3D.OPERATION_UNION + "intersection": node.operation = CSGShape3D.OPERATION_INTERSECTION + "subtraction": node.operation = CSGShape3D.OPERATION_SUBTRACTION + if params.has("name") and not (params["name"] as String).is_empty(): + node.name = params["name"] + parent.add_child(node) + node.owner = get_tree().edited_scene_root if get_tree().edited_scene_root else get_tree().root + _send_response({"success": true, "action": "create", "path": str(node.get_path()), "type": csg_type}) + elif action == "configure": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is CSGShape3D: + _send_response({"error": "CSGShape3D not found: %s" % node_path}) + return + if params.has("operation"): + match params["operation"]: + "union": (node as CSGShape3D).operation = CSGShape3D.OPERATION_UNION + "intersection": (node as CSGShape3D).operation = CSGShape3D.OPERATION_INTERSECTION + "subtraction": (node as CSGShape3D).operation = CSGShape3D.OPERATION_SUBTRACTION + _send_response({"success": true, "action": "configure", "path": str(node.get_path())}) + else: + _send_response({"error": "Unknown csg action: %s" % action}) + + +func _cmd_multimesh(params: Dictionary) -> void: + var action: String = params.get("action", "create") + match action: + "create": + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var mmi: MultiMeshInstance3D = MultiMeshInstance3D.new() + var mm: MultiMesh = MultiMesh.new() + mm.transform_format = MultiMesh.TRANSFORM_3D + mm.instance_count = int(params.get("count", 1)) + var mesh_type: String = params.get("mesh_type", "box") + match mesh_type: + "box": mm.mesh = BoxMesh.new() + "sphere": mm.mesh = SphereMesh.new() + "cylinder": mm.mesh = CylinderMesh.new() + _: mm.mesh = BoxMesh.new() + mmi.multimesh = mm + if params.has("name") and not (params["name"] as String).is_empty(): + mmi.name = params["name"] + parent.add_child(mmi) + _send_response({"success": true, "action": "create", "path": str(mmi.get_path()), "count": mm.instance_count}) + "set_instance": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is MultiMeshInstance3D: + _send_response({"error": "MultiMeshInstance3D not found: %s" % node_path}) + return + var idx: int = int(params.get("index", 0)) + var tf: Dictionary = params.get("transform", {}) + var origin: Dictionary = tf.get("origin", {}) + var xform: Transform3D = Transform3D.IDENTITY + xform.origin = Vector3(float(origin.get("x", 0)), float(origin.get("y", 0)), float(origin.get("z", 0))) + (node as MultiMeshInstance3D).multimesh.set_instance_transform(idx, xform) + _send_response({"success": true, "action": "set_instance", "index": idx}) + "get_info": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is MultiMeshInstance3D: + _send_response({"error": "MultiMeshInstance3D not found: %s" % node_path}) + return + var mm = (node as MultiMeshInstance3D).multimesh + _send_response({"success": true, "count": mm.instance_count if mm else 0, "visible_count": mm.visible_instance_count if mm else 0}) + _: + _send_response({"error": "Unknown multimesh action: %s" % action}) + + +func _cmd_procedural_mesh(params: Dictionary) -> void: + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var verts_arr: Array = params.get("vertices", []) + var verts: PackedVector3Array = PackedVector3Array() + for v in verts_arr: + verts.append(Vector3(float(v[0]), float(v[1]), float(v[2]))) + var arrays: Array = [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = verts + if params.has("normals"): + var norms: PackedVector3Array = PackedVector3Array() + for n in params["normals"]: + norms.append(Vector3(float(n[0]), float(n[1]), float(n[2]))) + arrays[Mesh.ARRAY_NORMAL] = norms + if params.has("uvs"): + var uvs: PackedVector2Array = PackedVector2Array() + for uv in params["uvs"]: + uvs.append(Vector2(float(uv[0]), float(uv[1]))) + arrays[Mesh.ARRAY_TEX_UV] = uvs + if params.has("indices"): + var indices: PackedInt32Array = PackedInt32Array() + for idx in params["indices"]: + indices.append(int(idx)) + arrays[Mesh.ARRAY_INDEX] = indices + var mesh: ArrayMesh = ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + var mi: MeshInstance3D = MeshInstance3D.new() + mi.mesh = mesh + if params.has("name") and not (params["name"] as String).is_empty(): + mi.name = params["name"] + parent.add_child(mi) + _send_response({"success": true, "path": str(mi.get_path()), "vertex_count": verts.size()}) + + +func _cmd_light_3d(params: Dictionary) -> void: + var action: String = params.get("action", "create") + if action == "create": + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var light_type: String = params.get("light_type", "omni") + var light: Light3D + match light_type: + "directional": light = DirectionalLight3D.new() + "omni": light = OmniLight3D.new() + "spot": light = SpotLight3D.new() + _: + _send_response({"error": "Unknown light type: %s" % light_type}) + return + if params.has("color"): + var c: Dictionary = params["color"] + light.light_color = Color(float(c.get("r", 1)), float(c.get("g", 1)), float(c.get("b", 1))) + if params.has("energy"): + light.light_energy = float(params["energy"]) + if params.has("shadows"): + light.shadow_enabled = bool(params["shadows"]) + if light is OmniLight3D and params.has("range"): + (light as OmniLight3D).omni_range = float(params["range"]) + if light is SpotLight3D: + if params.has("range"): + (light as SpotLight3D).spot_range = float(params["range"]) + if params.has("spot_angle"): + (light as SpotLight3D).spot_angle = float(params["spot_angle"]) + if params.has("name") and not (params["name"] as String).is_empty(): + light.name = params["name"] + parent.add_child(light) + _send_response({"success": true, "action": "create", "path": str(light.get_path()), "type": light_type}) + elif action == "configure": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is Light3D: + _send_response({"error": "Light3D not found: %s" % node_path}) + return + var light: Light3D = node as Light3D + if params.has("color"): + var c: Dictionary = params["color"] + light.light_color = Color(float(c.get("r", 1)), float(c.get("g", 1)), float(c.get("b", 1))) + if params.has("energy"): + light.light_energy = float(params["energy"]) + if params.has("shadows"): + light.shadow_enabled = bool(params["shadows"]) + _send_response({"success": true, "action": "configure", "path": str(node.get_path())}) + else: + _send_response({"error": "Unknown light_3d action: %s" % action}) + + +func _cmd_mesh_instance(params: Dictionary) -> void: + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var mesh_type: String = params.get("mesh_type", "box") + var mesh: Mesh + match mesh_type: + "box": mesh = BoxMesh.new() + "sphere": mesh = SphereMesh.new() + "cylinder": mesh = CylinderMesh.new() + "capsule": mesh = CapsuleMesh.new() + "plane": mesh = PlaneMesh.new() + "quad": mesh = QuadMesh.new() + _: + _send_response({"error": "Unknown mesh type: %s" % mesh_type}) + return + if params.has("size") and mesh is BoxMesh: + var s: Dictionary = params["size"] + (mesh as BoxMesh).size = Vector3(float(s.get("x", 1)), float(s.get("y", 1)), float(s.get("z", 1))) + if params.has("radius"): + if mesh is SphereMesh: (mesh as SphereMesh).radius = float(params["radius"]) + elif mesh is CylinderMesh: (mesh as CylinderMesh).top_radius = float(params["radius"]) + elif mesh is CapsuleMesh: (mesh as CapsuleMesh).radius = float(params["radius"]) + if params.has("height"): + if mesh is CylinderMesh: (mesh as CylinderMesh).height = float(params["height"]) + elif mesh is CapsuleMesh: (mesh as CapsuleMesh).height = float(params["height"]) + elif mesh is SphereMesh: (mesh as SphereMesh).height = float(params["height"]) + var mi: MeshInstance3D = MeshInstance3D.new() + mi.mesh = mesh + if params.has("material") and params["material"] is String: + var mat: StandardMaterial3D = StandardMaterial3D.new() + var hex: String = params["material"] + if hex.begins_with("#") or hex.length() == 6 or hex.length() == 8: + mat.albedo_color = Color.from_string(hex, Color.WHITE) + mi.material_override = mat + if params.has("name") and not (params["name"] as String).is_empty(): + mi.name = params["name"] + parent.add_child(mi) + _send_response({"success": true, "path": str(mi.get_path()), "mesh_type": mesh_type}) + + +func _cmd_gridmap(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is GridMap: + _send_response({"error": "GridMap not found: %s" % node_path}) + return + var gm: GridMap = node as GridMap + var action: String = params.get("action", "get_used") + match action: + "set_cell": + gm.set_cell_item(Vector3i(int(params.get("x", 0)), int(params.get("y", 0)), int(params.get("z", 0))), int(params.get("item", 0)), int(params.get("orientation", 0))) + _send_response({"success": true, "action": "set_cell"}) + "get_cell": + var item: int = gm.get_cell_item(Vector3i(int(params.get("x", 0)), int(params.get("y", 0)), int(params.get("z", 0)))) + _send_response({"success": true, "action": "get_cell", "item": item}) + "clear": + gm.clear() + _send_response({"success": true, "action": "clear"}) + "get_used": + var cells: Array = gm.get_used_cells() + var result: Array = [] + for c in cells.slice(0, 100): + result.append({"x": c.x, "y": c.y, "z": c.z}) + _send_response({"success": true, "action": "get_used", "cells": result, "total": cells.size()}) + _: + _send_response({"error": "Unknown gridmap action: %s" % action}) + + +func _cmd_3d_effects(params: Dictionary) -> void: + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var effect_type: String = params.get("effect_type", "") + var node: Node3D + match effect_type: + "reflection_probe": node = ReflectionProbe.new() + "decal": node = Decal.new() + "fog_volume": node = FogVolume.new() + _: + _send_response({"error": "Unknown effect type: %s" % effect_type}) + return + if params.has("size"): + var s: Dictionary = params["size"] + var size_v: Vector3 = Vector3(float(s.get("x", 1)), float(s.get("y", 1)), float(s.get("z", 1))) + if node is ReflectionProbe: (node as ReflectionProbe).size = size_v + elif node is Decal: (node as Decal).size = size_v + elif node is FogVolume: (node as FogVolume).size = size_v + if params.has("name") and not (params["name"] as String).is_empty(): + node.name = params["name"] + parent.add_child(node) + _send_response({"success": true, "path": str(node.get_path()), "effect_type": effect_type}) + + +func _cmd_gi(params: Dictionary) -> void: + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var gi_type: String = params.get("gi_type", "voxel_gi") + var node: VisualInstance3D + match gi_type: + "voxel_gi": node = VoxelGI.new() + "lightmap_gi": node = LightmapGI.new() + _: + _send_response({"error": "Unknown GI type: %s" % gi_type}) + return + if params.has("size") and node is VoxelGI: + var s: Dictionary = params["size"] + (node as VoxelGI).size = Vector3(float(s.get("x", 10)), float(s.get("y", 10)), float(s.get("z", 10))) + if params.has("name") and not (params["name"] as String).is_empty(): + node.name = params["name"] + parent.add_child(node) + _send_response({"success": true, "path": str(node.get_path()), "gi_type": gi_type}) + + +func _cmd_path_3d(params: Dictionary) -> void: + var action: String = params.get("action", "create") + match action: + "create": + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var path_node: Path3D = Path3D.new() + path_node.curve = Curve3D.new() + if params.has("name") and not (params["name"] as String).is_empty(): + path_node.name = params["name"] + if params.has("points"): + for p in params["points"]: + path_node.curve.add_point(Vector3(float(p.get("x", 0)), float(p.get("y", 0)), float(p.get("z", 0)))) + parent.add_child(path_node) + _send_response({"success": true, "action": "create", "path": str(path_node.get_path()), "point_count": path_node.curve.point_count}) + "add_point": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is Path3D: + _send_response({"error": "Path3D not found: %s" % node_path}) + return + var p: Dictionary = params.get("point", {}) + (node as Path3D).curve.add_point(Vector3(float(p.get("x", 0)), float(p.get("y", 0)), float(p.get("z", 0)))) + _send_response({"success": true, "action": "add_point", "point_count": (node as Path3D).curve.point_count}) + "get_points": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is Path3D: + _send_response({"error": "Path3D not found: %s" % node_path}) + return + var pts: Array = [] + for i in (node as Path3D).curve.point_count: + var pt: Vector3 = (node as Path3D).curve.get_point_position(i) + pts.append({"x": pt.x, "y": pt.y, "z": pt.z}) + _send_response({"success": true, "action": "get_points", "points": pts}) + _: + _send_response({"error": "Unknown path_3d action: %s" % action}) + + +func _cmd_sky(params: Dictionary) -> void: + var action: String = params.get("action", "create") + var env: Environment = _get_or_create_environment() + if env == null: + _send_response({"error": "Could not get or create environment"}) + return + var sky_type: String = params.get("sky_type", "procedural") + if action == "create" or env.sky == null: + env.sky = Sky.new() + env.background_mode = Environment.BG_SKY + var sky_mat: ProceduralSkyMaterial = ProceduralSkyMaterial.new() + if params.has("top_color"): + var c: Dictionary = params["top_color"] + sky_mat.sky_top_color = Color(float(c.get("r", 0.4)), float(c.get("g", 0.6)), float(c.get("b", 1.0))) + if params.has("bottom_color"): + var c: Dictionary = params["bottom_color"] + sky_mat.sky_horizon_color = Color(float(c.get("r", 0.7)), float(c.get("g", 0.8)), float(c.get("b", 0.9))) + if params.has("ground_color"): + var c: Dictionary = params["ground_color"] + sky_mat.ground_bottom_color = Color(float(c.get("r", 0.1)), float(c.get("g", 0.1)), float(c.get("b", 0.1))) + if params.has("sun_energy"): + sky_mat.sun_curve = float(params["sun_energy"]) + env.sky.sky_material = sky_mat + _send_response({"success": true, "action": action, "sky_type": sky_type}) + + +func _get_or_create_environment() -> Environment: + var cam: Camera3D = get_viewport().get_camera_3d() + if cam != null and cam.get_environment() != null: + return cam.get_environment() + var we: WorldEnvironment = null + for child in get_tree().root.get_children(): + if child is WorldEnvironment: + we = child as WorldEnvironment + break + if we != null and we.environment != null: + return we.environment + # Create one + we = WorldEnvironment.new() + we.environment = Environment.new() + get_tree().root.add_child(we) + return we.environment + + +func _cmd_camera_attributes(params: Dictionary) -> void: + var action: String = params.get("action", "get") + var cam: Camera3D = get_viewport().get_camera_3d() + if cam == null: + _send_response({"error": "No Camera3D found in viewport"}) + return + if action == "get": + var info: Dictionary = {"success": true, "action": "get"} + if cam.attributes != null: + info["has_attributes"] = true + else: + info["has_attributes"] = false + _send_response(info) + return + # set + if cam.attributes == null: + cam.attributes = CameraAttributesPractical.new() + var attr: CameraAttributesPractical = cam.attributes as CameraAttributesPractical + if attr == null: + _send_response({"error": "Camera attributes is not CameraAttributesPractical"}) + return + if params.has("dof_blur_far"): + attr.dof_blur_far_enabled = true + attr.dof_blur_far_distance = float(params["dof_blur_far"]) + if params.has("dof_blur_near"): + attr.dof_blur_near_enabled = true + attr.dof_blur_near_distance = float(params["dof_blur_near"]) + if params.has("dof_blur_amount"): + attr.dof_blur_amount = float(params["dof_blur_amount"]) + if params.has("auto_exposure"): + attr.auto_exposure_enabled = bool(params["auto_exposure"]) + _send_response({"success": true, "action": "set"}) + + +func _cmd_navigation_3d(params: Dictionary) -> void: + var action: String = params.get("action", "create") + match action: + "create": + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var region: NavigationRegion3D = NavigationRegion3D.new() + region.navigation_mesh = NavigationMesh.new() + if params.has("cell_size"): + region.navigation_mesh.cell_size = float(params["cell_size"]) + if params.has("agent_radius"): + region.navigation_mesh.agent_radius = float(params["agent_radius"]) + if params.has("agent_height"): + region.navigation_mesh.agent_height = float(params["agent_height"]) + if params.has("name") and not (params["name"] as String).is_empty(): + region.name = params["name"] + parent.add_child(region) + _send_response({"success": true, "action": "create", "path": str(region.get_path())}) + "bake": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is NavigationRegion3D: + _send_response({"error": "NavigationRegion3D not found: %s" % node_path}) + return + (node as NavigationRegion3D).bake_navigation_mesh() + await get_tree().process_frame + await get_tree().process_frame + _send_response({"success": true, "action": "bake"}) + _: + _send_response({"error": "Unknown navigation_3d action: %s" % action}) + + +func _cmd_physics_3d(params: Dictionary) -> void: + var action: String = params.get("action", "ray") + await get_tree().physics_frame + var space: PhysicsDirectSpaceState3D = get_viewport().world_3d.direct_space_state + match action: + "ray": + var from_d: Dictionary = params.get("from", {}) + var to_d: Dictionary = params.get("to", {}) + var from: Vector3 = Vector3(float(from_d.get("x", 0)), float(from_d.get("y", 0)), float(from_d.get("z", 0))) + var to: Vector3 = Vector3(float(to_d.get("x", 0)), float(to_d.get("y", 0)), float(to_d.get("z", 0))) + var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to) + if params.has("collision_mask"): + query.collision_mask = int(params["collision_mask"]) + var result: Dictionary = space.intersect_ray(query) + if result.is_empty(): + _send_response({"success": true, "action": "ray", "hit": false}) + else: + _send_response({"success": true, "action": "ray", "hit": true, "position": _variant_to_json(result["position"]), "normal": _variant_to_json(result["normal"]), "collider": str(result.get("collider", ""))}) + "overlap": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is Area3D: + _send_response({"error": "Area3D not found: %s" % node_path}) + return + var bodies: Array = (node as Area3D).get_overlapping_bodies() + var result: Array = [] + for b in bodies: + result.append({"name": b.name, "path": str(b.get_path())}) + _send_response({"success": true, "action": "overlap", "bodies": result}) + _: + _send_response({"error": "Unknown physics_3d action: %s" % action}) + + +# ========================================================================== +# Batch 3: 2D Systems + Animation Advanced + Audio Effects +# ========================================================================== + +func _cmd_canvas(params: Dictionary) -> void: + var action: String = params.get("action", "create_layer") + match action: + "create_layer": + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var cl: CanvasLayer = CanvasLayer.new() + if params.has("layer"): + cl.layer = int(params["layer"]) + if params.has("name") and not (params["name"] as String).is_empty(): + cl.name = params["name"] + parent.add_child(cl) + _send_response({"success": true, "action": "create_layer", "path": str(cl.get_path())}) + "create_modulate": + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var cm: CanvasModulate = CanvasModulate.new() + if params.has("color"): + var c: Dictionary = params["color"] + cm.color = Color(float(c.get("r", 1)), float(c.get("g", 1)), float(c.get("b", 1)), float(c.get("a", 1))) + if params.has("name") and not (params["name"] as String).is_empty(): + cm.name = params["name"] + parent.add_child(cm) + _send_response({"success": true, "action": "create_modulate", "path": str(cm.get_path())}) + _: + _send_response({"error": "Unknown canvas action: %s" % action}) + + +var _canvas_draw_node: Node2D = null +var _draw_commands: Array = [] + +func _cmd_canvas_draw(params: Dictionary) -> void: + var action: String = params.get("action", "line") + if action == "clear": + _draw_commands.clear() + if _canvas_draw_node != null and is_instance_valid(_canvas_draw_node): + _canvas_draw_node.queue_redraw() + _send_response({"success": true, "action": "clear"}) + return + # Ensure draw node + if _canvas_draw_node == null or not is_instance_valid(_canvas_draw_node): + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + _canvas_draw_node = Node2D.new() + _canvas_draw_node.name = "_McpCanvasDraw" + _canvas_draw_node.set_script(_create_draw_script()) + parent.add_child(_canvas_draw_node) + _canvas_draw_node.set("draw_commands", _draw_commands) + var color_d: Dictionary = params.get("color", {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}) + var color: Color = Color(float(color_d.get("r", 1)), float(color_d.get("g", 1)), float(color_d.get("b", 1)), float(color_d.get("a", 1))) + _draw_commands.append({"action": action, "params": params, "color": color}) + _canvas_draw_node.set("draw_commands", _draw_commands) + _canvas_draw_node.queue_redraw() + _send_response({"success": true, "action": action}) + +func _create_draw_script() -> GDScript: + var s: GDScript = GDScript.new() + s.source_code = """extends Node2D +var draw_commands: Array = [] +func _draw(): + for cmd in draw_commands: + var p = cmd.params + var c = cmd.color + match cmd.action: + "line": + var f = p.get("from", {}) + var t = p.get("to", {}) + draw_line(Vector2(float(f.get("x",0)),float(f.get("y",0))),Vector2(float(t.get("x",0)),float(t.get("y",0))),c,float(p.get("width",2))) + "rect": + var r = p.get("rect", {}) + draw_rect(Rect2(float(r.get("x",0)),float(r.get("y",0)),float(r.get("w",10)),float(r.get("h",10))),c,bool(p.get("filled",true))) + "circle": + var ct = p.get("center", {}) + draw_circle(Vector2(float(ct.get("x",0)),float(ct.get("y",0))),float(p.get("radius",10)),c) +""" + s.reload() + return s + + +func _cmd_light_2d(params: Dictionary) -> void: + var action: String = params.get("action", "create_point") + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + match action: + "create_point": + var light: PointLight2D = PointLight2D.new() + if params.has("color"): + var c: Dictionary = params["color"] + light.color = Color(float(c.get("r", 1)), float(c.get("g", 1)), float(c.get("b", 1)), float(c.get("a", 1))) + if params.has("energy"): + light.energy = float(params["energy"]) + # Create a simple gradient texture for the light + var tex: GradientTexture2D = GradientTexture2D.new() + tex.width = 128 + tex.height = 128 + tex.fill = GradientTexture2D.FILL_RADIAL + tex.gradient = Gradient.new() + light.texture = tex + if params.has("range"): + light.texture_scale = float(params["range"]) + if params.has("name") and not (params["name"] as String).is_empty(): + light.name = params["name"] + parent.add_child(light) + _send_response({"success": true, "action": "create_point", "path": str(light.get_path())}) + "create_directional": + var light: DirectionalLight2D = DirectionalLight2D.new() + if params.has("color"): + var c: Dictionary = params["color"] + light.color = Color(float(c.get("r", 1)), float(c.get("g", 1)), float(c.get("b", 1)), float(c.get("a", 1))) + if params.has("energy"): + light.energy = float(params["energy"]) + if params.has("name") and not (params["name"] as String).is_empty(): + light.name = params["name"] + parent.add_child(light) + _send_response({"success": true, "action": "create_directional", "path": str(light.get_path())}) + _: + _send_response({"error": "Unknown light_2d action: %s" % action}) + + +func _cmd_parallax(params: Dictionary) -> void: + var action: String = params.get("action", "create_background") + match action: + "create_background": + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var bg: ParallaxBackground = ParallaxBackground.new() + if params.has("name") and not (params["name"] as String).is_empty(): + bg.name = params["name"] + parent.add_child(bg) + _send_response({"success": true, "action": "create_background", "path": str(bg.get_path())}) + "add_layer": + var parent_path: String = params.get("parent_path", "") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null or not parent is ParallaxBackground: + _send_response({"error": "ParallaxBackground not found: %s" % parent_path}) + return + var layer: ParallaxLayer = ParallaxLayer.new() + if params.has("motion_scale"): + var ms: Dictionary = params["motion_scale"] + layer.motion_scale = Vector2(float(ms.get("x", 1)), float(ms.get("y", 1))) + if params.has("motion_offset"): + var mo: Dictionary = params["motion_offset"] + layer.motion_offset = Vector2(float(mo.get("x", 0)), float(mo.get("y", 0))) + if params.has("mirroring"): + var mi: Dictionary = params["mirroring"] + layer.motion_mirroring = Vector2(float(mi.get("x", 0)), float(mi.get("y", 0))) + if params.has("name") and not (params["name"] as String).is_empty(): + layer.name = params["name"] + parent.add_child(layer) + _send_response({"success": true, "action": "add_layer", "path": str(layer.get_path())}) + _: + _send_response({"error": "Unknown parallax action: %s" % action}) + + +func _cmd_shape_2d(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + var action: String = params.get("action", "get_points") + match action: + "add_point": + var p: Dictionary = params.get("point", {}) + var pt: Vector2 = Vector2(float(p.get("x", 0)), float(p.get("y", 0))) + if node is Line2D: + (node as Line2D).add_point(pt) + elif node is Polygon2D: + var polygon: PackedVector2Array = (node as Polygon2D).polygon + polygon.append(pt) + (node as Polygon2D).polygon = polygon + _send_response({"success": true, "action": "add_point"}) + "set_points": + var pts: Array = params.get("points", []) + var packed: PackedVector2Array = PackedVector2Array() + for p in pts: + packed.append(Vector2(float(p.get("x", 0)), float(p.get("y", 0)))) + if node is Line2D: + (node as Line2D).points = packed + elif node is Polygon2D: + (node as Polygon2D).polygon = packed + _send_response({"success": true, "action": "set_points", "count": packed.size()}) + "clear": + if node is Line2D: + (node as Line2D).clear_points() + elif node is Polygon2D: + (node as Polygon2D).polygon = PackedVector2Array() + _send_response({"success": true, "action": "clear"}) + "get_points": + var pts: PackedVector2Array + if node is Line2D: + pts = (node as Line2D).points + elif node is Polygon2D: + pts = (node as Polygon2D).polygon + else: + _send_response({"error": "Node is not Line2D or Polygon2D"}) + return + var result: Array = [] + for p in pts: + result.append({"x": p.x, "y": p.y}) + _send_response({"success": true, "action": "get_points", "points": result}) + _: + _send_response({"error": "Unknown shape_2d action: %s" % action}) + + +func _cmd_path_2d(params: Dictionary) -> void: + var action: String = params.get("action", "create") + match action: + "create": + var parent_path: String = params.get("parent_path", "/root") + var parent: Node = get_tree().root.get_node_or_null(parent_path) + if parent == null: + _send_response({"error": "Parent not found: %s" % parent_path}) + return + var path_node: Path2D = Path2D.new() + path_node.curve = Curve2D.new() + if params.has("name") and not (params["name"] as String).is_empty(): + path_node.name = params["name"] + if params.has("points"): + for p in params["points"]: + path_node.curve.add_point(Vector2(float(p.get("x", 0)), float(p.get("y", 0)))) + parent.add_child(path_node) + _send_response({"success": true, "action": "create", "path": str(path_node.get_path()), "point_count": path_node.curve.point_count}) + "add_point": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is Path2D: + _send_response({"error": "Path2D not found: %s" % node_path}) + return + var p: Dictionary = params.get("point", {}) + (node as Path2D).curve.add_point(Vector2(float(p.get("x", 0)), float(p.get("y", 0)))) + _send_response({"success": true, "action": "add_point", "point_count": (node as Path2D).curve.point_count}) + "get_points": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is Path2D: + _send_response({"error": "Path2D not found: %s" % node_path}) + return + var pts: Array = [] + for i in (node as Path2D).curve.point_count: + var pt: Vector2 = (node as Path2D).curve.get_point_position(i) + pts.append({"x": pt.x, "y": pt.y}) + _send_response({"success": true, "action": "get_points", "points": pts}) + _: + _send_response({"error": "Unknown path_2d action: %s" % action}) + + +func _cmd_physics_2d(params: Dictionary) -> void: + var action: String = params.get("action", "ray") + await get_tree().physics_frame + var space: PhysicsDirectSpaceState2D = get_viewport().world_2d.direct_space_state + match action: + "ray": + var from_d: Dictionary = params.get("from", {}) + var to_d: Dictionary = params.get("to", {}) + var from: Vector2 = Vector2(float(from_d.get("x", 0)), float(from_d.get("y", 0))) + var to: Vector2 = Vector2(float(to_d.get("x", 0)), float(to_d.get("y", 0))) + var query: PhysicsRayQueryParameters2D = PhysicsRayQueryParameters2D.create(from, to) + if params.has("collision_mask"): + query.collision_mask = int(params["collision_mask"]) + var result: Dictionary = space.intersect_ray(query) + if result.is_empty(): + _send_response({"success": true, "action": "ray", "hit": false}) + else: + _send_response({"success": true, "action": "ray", "hit": true, "position": _variant_to_json(result["position"]), "normal": _variant_to_json(result["normal"]), "collider": str(result.get("collider", ""))}) + "overlap": + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is Area2D: + _send_response({"error": "Area2D not found: %s" % node_path}) + return + var bodies: Array = (node as Area2D).get_overlapping_bodies() + var result: Array = [] + for b in bodies: + result.append({"name": b.name, "path": str(b.get_path())}) + _send_response({"success": true, "action": "overlap", "bodies": result}) + _: + _send_response({"error": "Unknown physics_2d action: %s" % action}) + + +func _cmd_animation_tree(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is AnimationTree: + _send_response({"error": "AnimationTree not found: %s" % node_path}) + return + var tree: AnimationTree = node as AnimationTree + var action: String = params.get("action", "get_state") + match action: + "travel": + var state_name: String = params.get("state_name", "") + var playback = tree.get("parameters/playback") + if playback != null: + playback.travel(state_name) + _send_response({"success": true, "action": "travel", "state": state_name}) + "set_param": + var param_name: String = params.get("param_name", "") + var param_value = params.get("param_value", 0) + tree.set("parameters/" + param_name, param_value) + _send_response({"success": true, "action": "set_param", "param": param_name}) + "get_state": + var playback = tree.get("parameters/playback") + var current: String = "" + if playback != null: + current = playback.get_current_node() + _send_response({"success": true, "action": "get_state", "current": current}) + _: + _send_response({"error": "Unknown animation_tree action: %s" % action}) + + +func _cmd_animation_control(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is AnimationPlayer: + _send_response({"error": "AnimationPlayer not found: %s" % node_path}) + return + var player: AnimationPlayer = node as AnimationPlayer + var action: String = params.get("action", "get_info") + match action: + "seek": + var pos: float = float(params.get("position", 0)) + player.seek(pos) + _send_response({"success": true, "action": "seek", "position": pos}) + "queue": + var anim: String = params.get("animation_name", "") + player.queue(anim) + _send_response({"success": true, "action": "queue", "animation": anim}) + "set_speed": + player.speed_scale = float(params.get("speed", 1.0)) + _send_response({"success": true, "action": "set_speed", "speed": player.speed_scale}) + "stop": + player.stop() + _send_response({"success": true, "action": "stop"}) + "get_info": + var anims: PackedStringArray = player.get_animation_list() + _send_response({"success": true, "action": "get_info", "current": player.current_animation, "playing": player.is_playing(), "animations": Array(anims), "speed_scale": player.speed_scale, "position": player.current_animation_position}) + _: + _send_response({"error": "Unknown animation_control action: %s" % action}) + + +func _cmd_skeleton_ik(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is SkeletonIK3D: + _send_response({"error": "SkeletonIK3D not found: %s" % node_path}) + return + var ik: SkeletonIK3D = node as SkeletonIK3D + var action: String = params.get("action", "start") + match action: + "start": + ik.start() + _send_response({"success": true, "action": "start"}) + "stop": + ik.stop() + _send_response({"success": true, "action": "stop"}) + "set_target": + var t: Dictionary = params.get("target", {}) + var target_tf: Transform3D = Transform3D.IDENTITY + target_tf.origin = Vector3(float(t.get("x", 0)), float(t.get("y", 0)), float(t.get("z", 0))) + ik.target = target_tf + _send_response({"success": true, "action": "set_target"}) + _: + _send_response({"error": "Unknown skeleton_ik action: %s" % action}) + + +func _cmd_audio_effect(params: Dictionary) -> void: + var bus_name: String = params.get("bus_name", "Master") + var bus_idx: int = AudioServer.get_bus_index(bus_name) + if bus_idx < 0: + _send_response({"error": "Audio bus not found: %s" % bus_name}) + return + var action: String = params.get("action", "list") + match action: + "list": + var effects: Array = [] + for i in AudioServer.get_bus_effect_count(bus_idx): + var eff: AudioEffect = AudioServer.get_bus_effect(bus_idx, i) + effects.append({"index": i, "type": eff.get_class(), "enabled": AudioServer.is_bus_effect_enabled(bus_idx, i)}) + _send_response({"success": true, "action": "list", "bus": bus_name, "effects": effects}) + "add": + var effect_type: String = params.get("effect_type", "reverb") + var effect: AudioEffect + match effect_type: + "reverb": effect = AudioEffectReverb.new() + "delay": effect = AudioEffectDelay.new() + "chorus": effect = AudioEffectChorus.new() + "eq": effect = AudioEffectEQ6.new() + "compressor": effect = AudioEffectCompressor.new() + "limiter": effect = AudioEffectLimiter.new() + _: + _send_response({"error": "Unknown effect type: %s" % effect_type}) + return + AudioServer.add_bus_effect(bus_idx, effect) + _send_response({"success": true, "action": "add", "effect_type": effect_type, "index": AudioServer.get_bus_effect_count(bus_idx) - 1}) + "remove": + var idx: int = int(params.get("index", 0)) + AudioServer.remove_bus_effect(bus_idx, idx) + _send_response({"success": true, "action": "remove", "index": idx}) + _: + _send_response({"error": "Unknown audio_effect action: %s" % action}) + + +func _cmd_audio_bus_layout(params: Dictionary) -> void: + var action: String = params.get("action", "list") + match action: + "list": + var buses: Array = [] + for i in AudioServer.bus_count: + buses.append({"index": i, "name": AudioServer.get_bus_name(i), "volume": AudioServer.get_bus_volume_db(i), "mute": AudioServer.is_bus_mute(i), "solo": AudioServer.is_bus_solo(i), "send": AudioServer.get_bus_send(i), "effect_count": AudioServer.get_bus_effect_count(i)}) + _send_response({"success": true, "action": "list", "buses": buses}) + "add": + var bus_name: String = params.get("bus_name", "New Bus") + AudioServer.add_bus() + var idx: int = AudioServer.bus_count - 1 + AudioServer.set_bus_name(idx, bus_name) + _send_response({"success": true, "action": "add", "bus_name": bus_name, "index": idx}) + "remove": + var bus_name: String = params.get("bus_name", "") + var idx: int = AudioServer.get_bus_index(bus_name) + if idx <= 0: + _send_response({"error": "Cannot remove bus: %s" % bus_name}) + return + AudioServer.remove_bus(idx) + _send_response({"success": true, "action": "remove", "bus_name": bus_name}) + "set_send": + var bus_name: String = params.get("bus_name", "") + var send_to: String = params.get("send_to", "Master") + var idx: int = AudioServer.get_bus_index(bus_name) + if idx < 0: + _send_response({"error": "Bus not found: %s" % bus_name}) + return + AudioServer.set_bus_send(idx, send_to) + _send_response({"success": true, "action": "set_send", "bus": bus_name, "send_to": send_to}) + _: + _send_response({"error": "Unknown audio_bus_layout action: %s" % action}) + + +func _cmd_audio_spatial(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is AudioStreamPlayer3D: + _send_response({"error": "AudioStreamPlayer3D not found: %s" % node_path}) + return + var player: AudioStreamPlayer3D = node as AudioStreamPlayer3D + var action: String = params.get("action", "get_info") + if action == "get_info": + _send_response({"success": true, "max_distance": player.max_distance, "unit_size": player.unit_size, "max_db": player.max_db, "playing": player.playing}) + return + if params.has("max_distance"): + player.max_distance = float(params["max_distance"]) + if params.has("unit_size"): + player.unit_size = float(params["unit_size"]) + if params.has("max_db"): + player.max_db = float(params["max_db"]) + _send_response({"success": true, "action": "configure"}) + + +# ========================================================================== +# Batch 4: Locale (runtime) +# ========================================================================== + +func _cmd_locale(params: Dictionary) -> void: + var action: String = params.get("action", "get") + match action: + "get": + _send_response({"success": true, "locale": TranslationServer.get_locale()}) + "set": + var locale: String = params.get("locale", "en") + TranslationServer.set_locale(locale) + _send_response({"success": true, "action": "set", "locale": locale}) + "translate": + var key: String = params.get("key", "") + var translated: String = tr(key) + _send_response({"success": true, "key": key, "translated": translated}) + _: + _send_response({"error": "Unknown locale action: %s" % action}) + + +# ========================================================================== +# Batch 5: UI Controls + Rendering + Resource Runtime +# ========================================================================== + +func _cmd_ui_control(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is Control: + _send_response({"error": "Control not found: %s" % node_path}) + return + var ctrl: Control = node as Control + var action: String = params.get("action", "get_info") + match action: + "grab_focus": + ctrl.grab_focus() + _send_response({"success": true, "action": "grab_focus"}) + "release_focus": + ctrl.release_focus() + _send_response({"success": true, "action": "release_focus"}) + "configure": + if params.has("tooltip"): + ctrl.tooltip_text = str(params["tooltip"]) + if params.has("mouse_filter"): + match params["mouse_filter"]: + "stop": ctrl.mouse_filter = Control.MOUSE_FILTER_STOP + "pass": ctrl.mouse_filter = Control.MOUSE_FILTER_PASS + "ignore": ctrl.mouse_filter = Control.MOUSE_FILTER_IGNORE + if params.has("min_size"): + var s: Dictionary = params["min_size"] + ctrl.custom_minimum_size = Vector2(float(s.get("x", 0)), float(s.get("y", 0))) + _send_response({"success": true, "action": "configure"}) + "get_info": + _send_response({"success": true, "size": _variant_to_json(ctrl.size), "position": _variant_to_json(ctrl.position), "has_focus": ctrl.has_focus(), "visible": ctrl.visible, "tooltip": ctrl.tooltip_text, "mouse_filter": ctrl.mouse_filter}) + _: + _send_response({"error": "Unknown ui_control action: %s" % action}) + + +func _cmd_ui_text(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + var action: String = params.get("action", "get") + match action: + "get": + var text: String = "" + if node is LineEdit: text = (node as LineEdit).text + elif node is TextEdit: text = (node as TextEdit).text + elif node is RichTextLabel: text = (node as RichTextLabel).text + else: + _send_response({"error": "Node is not a text control"}) + return + _send_response({"success": true, "text": text}) + "set": + var text: String = str(params.get("text", "")) + if node is LineEdit: (node as LineEdit).text = text + elif node is TextEdit: (node as TextEdit).text = text + elif node is RichTextLabel: (node as RichTextLabel).text = text + _send_response({"success": true, "action": "set"}) + "append": + var text: String = str(params.get("text", "")) + if node is TextEdit: (node as TextEdit).text += text + elif node is RichTextLabel: (node as RichTextLabel).append_text(text) + _send_response({"success": true, "action": "append"}) + "clear": + if node is LineEdit: (node as LineEdit).text = "" + elif node is TextEdit: (node as TextEdit).text = "" + elif node is RichTextLabel: (node as RichTextLabel).clear() + _send_response({"success": true, "action": "clear"}) + "bbcode": + if node is RichTextLabel: + (node as RichTextLabel).bbcode_enabled = true + (node as RichTextLabel).text = str(params.get("text", "")) + _send_response({"success": true, "action": "bbcode"}) + _: + _send_response({"error": "Unknown ui_text action: %s" % action}) + + +func _cmd_ui_popup(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is Window: + _send_response({"error": "Window/Popup not found: %s" % node_path}) + return + var win: Window = node as Window + var action: String = params.get("action", "popup_centered") + match action: + "popup_centered": + if params.has("size"): + var s: Dictionary = params["size"] + win.popup_centered(Vector2i(int(s.get("x", 200)), int(s.get("y", 100)))) + else: + win.popup_centered() + _send_response({"success": true, "action": "popup_centered"}) + "popup": + win.popup() + _send_response({"success": true, "action": "popup"}) + "hide": + win.hide() + _send_response({"success": true, "action": "hide"}) + "get_info": + _send_response({"success": true, "visible": win.visible, "title": win.title, "size": _variant_to_json(win.size)}) + _: + _send_response({"error": "Unknown ui_popup action: %s" % action}) + + +func _cmd_ui_tree(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is Tree: + _send_response({"error": "Tree not found: %s" % node_path}) + return + var tree: Tree = node as Tree + var action: String = params.get("action", "get_items") + match action: + "get_items": + var items: Array = [] + var root: TreeItem = tree.get_root() + if root != null: + _collect_tree_items(root, items, 0) + _send_response({"success": true, "action": "get_items", "items": items}) + "add": + var text: String = str(params.get("text", "Item")) + var root: TreeItem = tree.get_root() + if root == null: + root = tree.create_item() + var item: TreeItem = tree.create_item(root) + item.set_text(int(params.get("column", 0)), text) + _send_response({"success": true, "action": "add", "text": text}) + _: + _send_response({"error": "Unknown ui_tree action: %s" % action}) + +func _collect_tree_items(item: TreeItem, result: Array, depth: int) -> void: + var col: int = 0 + result.append({"text": item.get_text(col), "depth": depth, "collapsed": item.collapsed}) + var child: TreeItem = item.get_first_child() + while child != null: + _collect_tree_items(child, result, depth + 1) + child = child.get_next() + + +func _cmd_ui_item_list(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + var action: String = params.get("action", "get_items") + if node is ItemList: + var il: ItemList = node as ItemList + match action: + "get_items": + var items: Array = [] + for i in il.item_count: + items.append({"index": i, "text": il.get_item_text(i), "selected": il.is_selected(i)}) + _send_response({"success": true, "items": items}) + "select": + il.select(int(params.get("index", 0))) + _send_response({"success": true, "action": "select"}) + "add": + il.add_item(str(params.get("text", "Item"))) + _send_response({"success": true, "action": "add"}) + "remove": + il.remove_item(int(params.get("index", 0))) + _send_response({"success": true, "action": "remove"}) + "clear": + il.clear() + _send_response({"success": true, "action": "clear"}) + _: + _send_response({"error": "Unknown ui_item_list action: %s" % action}) + elif node is OptionButton: + var ob: OptionButton = node as OptionButton + match action: + "get_items": + var items: Array = [] + for i in ob.item_count: + items.append({"index": i, "text": ob.get_item_text(i)}) + _send_response({"success": true, "items": items, "selected": ob.selected}) + "select": + ob.select(int(params.get("index", 0))) + _send_response({"success": true, "action": "select"}) + "add": + ob.add_item(str(params.get("text", "Item"))) + _send_response({"success": true, "action": "add"}) + _: + _send_response({"error": "Unknown action for OptionButton: %s" % action}) + else: + _send_response({"error": "Node is not ItemList or OptionButton"}) + + +func _cmd_ui_tabs(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + var action: String = params.get("action", "get_tabs") + if node is TabContainer: + var tc: TabContainer = node as TabContainer + match action: + "get_tabs": + var tabs: Array = [] + for i in tc.get_tab_count(): + tabs.append({"index": i, "title": tc.get_tab_title(i)}) + _send_response({"success": true, "tabs": tabs, "current": tc.current_tab}) + "set_current": + tc.current_tab = int(params.get("index", 0)) + _send_response({"success": true, "action": "set_current"}) + "set_title": + tc.set_tab_title(int(params.get("index", 0)), str(params.get("title", ""))) + _send_response({"success": true, "action": "set_title"}) + _: + _send_response({"error": "Unknown ui_tabs action: %s" % action}) + elif node is TabBar: + var tb: TabBar = node as TabBar + match action: + "get_tabs": + var tabs: Array = [] + for i in tb.tab_count: + tabs.append({"index": i, "title": tb.get_tab_title(i)}) + _send_response({"success": true, "tabs": tabs, "current": tb.current_tab}) + "set_current": + tb.current_tab = int(params.get("index", 0)) + _send_response({"success": true, "action": "set_current"}) + _: + _send_response({"error": "Unknown ui_tabs action: %s" % action}) + else: + _send_response({"error": "Node is not TabContainer or TabBar"}) + + +func _cmd_ui_menu(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null or not node is PopupMenu: + _send_response({"error": "PopupMenu not found: %s" % node_path}) + return + var menu: PopupMenu = node as PopupMenu + var action: String = params.get("action", "get_items") + match action: + "get_items": + var items: Array = [] + for i in menu.item_count: + items.append({"index": i, "text": menu.get_item_text(i), "checked": menu.is_item_checked(i), "disabled": menu.is_item_disabled(i), "id": menu.get_item_id(i)}) + _send_response({"success": true, "items": items}) + "add": + var text: String = str(params.get("text", "Item")) + var id: int = int(params.get("id", -1)) + menu.add_item(text, id) + _send_response({"success": true, "action": "add"}) + "remove": + menu.remove_item(int(params.get("index", 0))) + _send_response({"success": true, "action": "remove"}) + "set_checked": + menu.set_item_checked(int(params.get("index", 0)), bool(params.get("checked", true))) + _send_response({"success": true, "action": "set_checked"}) + "clear": + menu.clear() + _send_response({"success": true, "action": "clear"}) + _: + _send_response({"error": "Unknown ui_menu action: %s" % action}) + + +func _cmd_ui_range(params: Dictionary) -> void: + var node_path: String = params.get("node_path", "") + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + var action: String = params.get("action", "get") + if node is Range: + var r: Range = node as Range + if action == "get": + _send_response({"success": true, "value": r.value, "min": r.min_value, "max": r.max_value, "step": r.step}) + return + if params.has("value"): r.value = float(params["value"]) + if params.has("min_value"): r.min_value = float(params["min_value"]) + if params.has("max_value"): r.max_value = float(params["max_value"]) + if params.has("step"): r.step = float(params["step"]) + _send_response({"success": true, "action": "set", "value": r.value}) + elif node is ColorPicker: + var cp: ColorPicker = node as ColorPicker + if action == "get": + var c: Color = cp.color + _send_response({"success": true, "color": {"r": c.r, "g": c.g, "b": c.b, "a": c.a}}) + return + if params.has("color"): + var cd: Dictionary = params["color"] + cp.color = Color(float(cd.get("r", 0)), float(cd.get("g", 0)), float(cd.get("b", 0)), float(cd.get("a", 1))) + _send_response({"success": true, "action": "set"}) + else: + _send_response({"error": "Node is not Range or ColorPicker"}) + + +func _cmd_render_settings(params: Dictionary) -> void: + var vp: Viewport = get_viewport() + var action: String = params.get("action", "get") + if action == "get": + _send_response({"success": true, "msaa_2d": vp.msaa_2d, "msaa_3d": vp.msaa_3d, "screen_space_aa": vp.screen_space_aa, "use_taa": vp.use_taa, "scaling_3d_mode": vp.scaling_3d_mode, "scaling_3d_scale": vp.scaling_3d_scale}) + return + if params.has("msaa_2d"): + vp.msaa_2d = int(params["msaa_2d"]) as Viewport.MSAA + if params.has("msaa_3d"): + vp.msaa_3d = int(params["msaa_3d"]) as Viewport.MSAA + if params.has("fxaa"): + vp.screen_space_aa = Viewport.SCREEN_SPACE_AA_FXAA if bool(params["fxaa"]) else Viewport.SCREEN_SPACE_AA_DISABLED + if params.has("taa"): + vp.use_taa = bool(params["taa"]) + if params.has("scaling_mode"): + vp.scaling_3d_mode = int(params["scaling_mode"]) as Viewport.Scaling3DMode + if params.has("scaling_scale"): + vp.scaling_3d_scale = float(params["scaling_scale"]) + _send_response({"success": true, "action": "set"}) + + +func _cmd_resource(params: Dictionary) -> void: + var action: String = params.get("action", "load") + var res_path: String = params.get("path", "") + match action: + "load": + if not ResourceLoader.exists(res_path): + _send_response({"error": "Resource not found: %s" % res_path}) + return + var res: Resource = ResourceLoader.load(res_path) + if res == null: + _send_response({"error": "Failed to load resource: %s" % res_path}) + return + _send_response({"success": true, "action": "load", "path": res_path, "type": res.get_class()}) + "save": + var node_path: String = params.get("node_path", "") + var prop: String = params.get("property", "") + if node_path.is_empty(): + _send_response({"error": "node_path is required for save"}) + return + var node: Node = get_tree().root.get_node_or_null(node_path) + if node == null: + _send_response({"error": "Node not found: %s" % node_path}) + return + var res = node.get(prop) if not prop.is_empty() else null + if res is Resource: + var err: int = ResourceSaver.save(res, res_path) + _send_response({"success": err == OK, "action": "save", "path": res_path}) + else: + _send_response({"error": "Property is not a Resource"}) + "exists": + _send_response({"success": true, "action": "exists", "path": res_path, "exists": ResourceLoader.exists(res_path)}) + _: + _send_response({"error": "Unknown resource action: %s" % action}) + + +func _exit_tree() -> void: + _clear_debug_draw() + if _websocket != null: + _websocket.close() + _websocket = null + if _client != null: + _client.disconnect_from_host() + _client = null + if _server != null: + _server.stop() + _server = null + print("McpInteractionServer: Stopped") diff --git a/mcp_interaction_server.gd.uid b/mcp_interaction_server.gd.uid new file mode 100644 index 0000000..5cf2424 --- /dev/null +++ b/mcp_interaction_server.gd.uid @@ -0,0 +1 @@ +uid://bxbjxf7qb74ki diff --git a/scenes/kq4_099_transitional_room/kq4_099_transitional_room.gd b/scenes/kq4_099_transitional_room/kq4_099_transitional_room.gd deleted file mode 100644 index e25277b..0000000 --- a/scenes/kq4_099_transitional_room/kq4_099_transitional_room.gd +++ /dev/null @@ -1,10 +0,0 @@ -extends Node2D -class_name TransitionalRoom - -@onready var ego: Node2D = $"../ego" - -func _ready() -> void: - pass - -func _on_room_looked() -> void: - pass diff --git a/scenes/kq4_099_transitional_room/kq4_099_transitional_room.gd.uid b/scenes/kq4_099_transitional_room/kq4_099_transitional_room.gd.uid deleted file mode 100644 index 3a27f78..0000000 --- a/scenes/kq4_099_transitional_room/kq4_099_transitional_room.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bg2in3fw1di73 diff --git a/scenes/kq4_099_transitional_room/kq4_099_transitional_room.tscn b/scenes/kq4_099_transitional_room/kq4_099_transitional_room.tscn deleted file mode 100644 index b7b576a..0000000 --- a/scenes/kq4_099_transitional_room/kq4_099_transitional_room.tscn +++ /dev/null @@ -1,30 +0,0 @@ -[gd_scene format=3 uid="uid://3uw3e4ki6p2md"] - -[ext_resource type="Script" path="res://scenes/kq4_099_transitional_room/kq4_099_transitional_room.gd" id="1_script"] -[ext_resource type="Script" uid="uid://xmphq3i0wbg3" path="res://ScalePoint_.gd" id="3_scale"] -[ext_resource type="PackedScene" uid="uid://c4vc1wx7k6cw" path="res://TransitionPiece.tscn" id="4_transition"] - -[sub_resource type="NavigationPolygon" id="NavigationPolygon_main"] -vertices = PackedVector2Array(100, 800, 1800, 800, 1800, 1200, 100, 1200) -polygons = Array[PackedInt32Array]([PackedInt32Array(0, 1, 2, 3)]) -outlines = Array[PackedVector2Array]([PackedVector2Array(100, 800, 1800, 800, 1800, 1200, 100, 1200)]) - -[node name="background" type="Node2D"] -y_sort_enabled = true -script = ExtResource("1_script") - -[node name="StartScalePoint" type="Node2D" parent="."] -position = Vector2(200, 900) -script = ExtResource("3_scale") -target_scale = 0.3 - -[node name="EndScalePoint" type="Node2D" parent="."] -position = Vector2(1800, 1100) -script = ExtResource("3_scale") -target_scale = 0.4 - -[node name="pathfind" type="NavigationRegion2D" parent="."] -navigation_polygon = SubResource("NavigationPolygon_main") - -[node name="default-starting-point" type="Node2D" parent="."] -position = Vector2(200, 900) diff --git a/scenes/kq4_099_transitional_room/kq4_099_transitional_room.tscn.uid b/scenes/kq4_099_transitional_room/kq4_099_transitional_room.tscn.uid deleted file mode 100644 index 12dae43..0000000 --- a/scenes/kq4_099_transitional_room/kq4_099_transitional_room.tscn.uid +++ /dev/null @@ -1 +0,0 @@ -uid://3uw3e4ki6p2md diff --git a/scenes/kq4_099_transitional_room/kq4_placeholder_template.gd.uid b/scenes/kq4_099_transitional_room/kq4_placeholder_template.gd.uid deleted file mode 100644 index c13dfde..0000000 --- a/scenes/kq4_099_transitional_room/kq4_placeholder_template.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bk8q2nad4pu44 diff --git a/scenes/kq4_099_transitional_room/pic_99_visual.png.import b/scenes/kq4_099_transitional_room/pic_99_visual.png.import deleted file mode 100644 index 44951e3..0000000 --- a/scenes/kq4_099_transitional_room/pic_99_visual.png.import +++ /dev/null @@ -1,40 +0,0 @@ -[remap] - -importer="texture" -type="CompressedTexture2D" -uid="uid://3vitc6ejvwys2" -path="res://.godot/imported/pic_99_visual.png-PLACEHOLDER.ctex" -metadata={ -"vram_texture": false -} - -[deps] - -source_file="res://scenes/kq4_099_transitional_room/pic_99_visual.png" -dest_files=["res://.godot/imported/pic_99_visual.png-PLACEHOLDER.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 diff --git a/scripts/build_room_graph.py b/scripts/build_room_graph.py new file mode 100644 index 0000000..9b6291b --- /dev/null +++ b/scripts/build_room_graph.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Builds a queryable room adjacency graph from .tscn files with BFS pathfinding. + +Parses all scene files to extract TransitionPiece exits, builds an adjacency +graph, and provides shortest-path queries between any two rooms. + +Usage: + python scripts/build_room_graph.py # print full graph + python scripts/build_room_graph.py --from kq4_001_beach --to kq4_092_lolottes_throne_room + +Can also be imported as a module for use by other tools. +""" + +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class TransitionInfo: + """A single room exit / TransitionPiece.""" + + exit_node_name: str # Node name in source room's scene tree (e.g., "kq4_010_forest_path") + target_uid: str # UID of destination .tscn file + appear_at_node: str # Node name in destination room where player appears + label: str # Human-readable label (e.g., "Forest Path") + polygon: list[tuple[float, float]] # Polygon vertices relative to transition node position + position: tuple[float, float] = (0.0, 0.0) # Node position in parent space + scale: tuple[float, float] = (1.0, 1.0) # Node scale factors + + def viewport_centroid(self) -> tuple[float, float]: + """Compute the polygon centroid transformed to viewport coordinates.""" + if not self.polygon: + return self.position + cx = sum(p[0] for p in self.polygon) / len(self.polygon) + cy = sum(p[1] for p in self.polygon) / len(self.polygon) + return ( + self.scale[0] * (self.position[0] + cx), + self.scale[1] * (self.position[1] + cy), + ) + + +@dataclass +class RoomInfo: + """Metadata for a single room.""" + + name: str # e.g., "kq4_004_ogres_cottage" + uid: Optional[str] # Scene UID from .tscn header + path: Path # Absolute path to .tscn file + transitions: list[TransitionInfo] = field(default_factory=list) + + +@dataclass(frozen=True) +class NavigationStep: + """One step in a navigation plan (source room → destination room).""" + + from_room: str # Source room name + exit_node_name: str # TransitionPiece node name to click on + to_room: str # Destination room name + label: str # Human-readable label + polygon: list[tuple[float, float]] + position: tuple[float, float] = (0.0, 0.0) + scale: tuple[float, float] = (1.0, 1.0) + + def viewport_centroid(self) -> tuple[float, float]: + """Compute the polygon centroid transformed to viewport coordinates.""" + if not self.polygon: + return self.position + cx = sum(p[0] for p in self.polygon) / len(self.polygon) + cy = sum(p[1] for p in self.polygon) / len(self.polygon) + return ( + self.scale[0] * (self.position[0] + cx), + self.scale[1] * (self.position[1] + cy), + ) + + +def find_uid_files(root: Path) -> dict[str, Path]: + """Build a mapping of UID → file path from all .uid files.""" + uid_map = {} + for uid_file in root.rglob("*.uid"): + content = uid_file.read_text().strip() + if content.startswith("uid://"): + resource_path = uid_file.with_suffix("") + uid_map[content] = resource_path + return uid_map + + +def parse_transitions(body: str, room_name: str) -> list[TransitionInfo]: + """Extract TransitionPiece transitions from a .tscn file body.""" + transitions = [] + + transition_pattern = re.compile( + r'\[node name="([^"]+)"[^\]]*instance=ExtResource\([^\)]+\)\]\s*\n' + r"(([^\[]+(?:\n|$))*?)(?=\[|$)", + re.MULTILINE, + ) + + for match in transition_pattern.finditer(body): + node_name = match.group(1) + body_text = match.group(2) + + target_match = re.search(r'^target = "([^"]+)"', body_text, re.MULTILINE) + appear_match = re.search( + r'^(?:appear_at_node = "([^"]+)"|appear_at_node = &"([^"]+)")', + body_text, + re.MULTILINE, + ) + + if not (target_match and appear_match): + continue + + target_uid = target_match.group(1) + appear_at = appear_match.group(1) or appear_match.group(2) + + label_match = re.search(r'^label = "([^"]+)"', body_text, re.MULTILINE) + label = label_match.group(1) if label_match else node_name + + # Parse position (defaults to 0, 0) + pos_match = re.search(r"^position\s*=\s*Vector2?\(([^)]+)\)", body_text, re.MULTILINE) + if pos_match: + pos_nums = [float(x.strip()) for x in pos_match.group(1).split(",")] + node_position = (pos_nums[0], pos_nums[1]) + else: + node_position = (0.0, 0.0) + + # Parse scale (defaults to 1, 1) + scale_match = re.search(r"^scale\s*=\s*Vector2?\(([^)]+)\)", body_text, re.MULTILINE) + if scale_match: + scale_nums = [float(x.strip()) for x in scale_match.group(1).split(",")] + node_scale = (scale_nums[0], scale_nums[1]) + else: + node_scale = (1.0, 1.0) + + polygon_str_match = re.search( + r"^polygon = PackedVector2Array\((.+?)\)", + body_text, + re.MULTILINE, + ) + polygon: list[tuple[float, float]] = [] + if polygon_str_match: + nums = re.findall(r"[-+]?[0-9]*\.?[0-9]+", polygon_str_match.group(1)) + floats = [float(n) for n in nums] + polygon = [(floats[i], floats[i + 1]) for i in range(0, len(floats), 2)] + + transitions.append( + TransitionInfo( + exit_node_name=node_name, + target_uid=target_uid, + appear_at_node=appear_at, + label=label, + polygon=polygon, + position=node_position, + scale=node_scale, + ) + ) + + return transitions + + +def build_graph(scenes_dir: Path) -> dict[str, RoomInfo]: + """Parse all room .tscn files and return a dict keyed by room name.""" + scene_files = sorted(scenes_dir.glob("kq4_*/kq4_*.tscn")) + + graph: dict[str, RoomInfo] = {} + for sf in scene_files: + if "placeholder_template" in str(sf): + continue + + content = sf.read_text() + uid_match = re.search(r'\[gd_scene[^\]]*uid="([^"]+)"', content) + room_uid = uid_match.group(1) if uid_match else None + + room_name = sf.stem + transitions = parse_transitions(content, room_name) + + graph[room_name] = RoomInfo( + name=room_name, + uid=room_uid, + path=sf, + transitions=transitions, + ) + + return graph + + +def _resolve_target_room(trans: TransitionInfo, graph: dict[str, RoomInfo]) -> Optional[str]: + """Given a transition and the room graph, find which room it targets.""" + for room_name, room_info in graph.items(): + if room_info.uid == trans.target_uid: + return room_name + return None + + +def find_path( + graph: dict[str, RoomInfo], start_room: str, end_room: str +) -> Optional[list[NavigationStep]]: + """BFS from start to end. Returns ordered list of steps or None.""" + if start_room not in graph: + raise ValueError(f"Start room not found in graph: {start_room}") + if end_room not in graph: + raise ValueError(f"End room not found in graph: {end_room}") + + if start_room == end_room: + return [] + + from collections import deque + + adj: dict[str, list[tuple[str, TransitionInfo]]] = {} + for rname, rinfo in graph.items(): + adj[rname] = [] + for t in rinfo.transitions: + target = _resolve_target_room(t, graph) + if target: + adj[rname].append((target, t)) + + visited = {start_room} + queue = deque([(start_room, [])]) + + while queue: + current, path_steps = queue.popleft() + for neighbor, trans in adj.get(current, []): + if neighbor in visited: + continue + + step = NavigationStep( + from_room=current, + exit_node_name=trans.exit_node_name, + to_room=neighbor, + label=trans.label, + polygon=trans.polygon, + position=trans.position, + scale=trans.scale, + ) + new_steps = path_steps + [step] + + if neighbor == end_room: + return new_steps + + visited.add(neighbor) + queue.append((neighbor, new_steps)) + + return None + + +def get_connected_components(graph: dict[str, RoomInfo]) -> list[set[str]]: + """Return list of connected components in the room graph.""" + adj: dict[str, set[str]] = {r: set() for r in graph} + for rname, rinfo in graph.items(): + for t in rinfo.transitions: + target = _resolve_target_room(t, graph) + if target: + adj[rname].add(target) + adj[target].add(rname) + + visited = set() + components = [] + + for room in graph: + if room in visited: + continue + component = set() + stack = [room] + while stack: + current = stack.pop() + if current in visited: + continue + visited.add(current) + component.add(current) + stack.extend(adj[current] - visited) + components.append(component) + + return sorted(components, key=len, reverse=True) + + +def print_graph_summary(graph: dict[str, RoomInfo]) -> None: + """Print a human-readable summary of the room graph.""" + total_rooms = len(graph) + total_exits = sum(len(r.transitions) for r in graph.values()) + components = get_connected_components(graph) + + print(f"Rooms: {total_rooms}") + print(f"Total transition exits: {total_exits}") + print(f"Connected components: {len(components)}") + + if len(components) > 1: + for i, comp in enumerate(components): + print(f" Component {i + 1} ({len(comp)} rooms): {', '.join(sorted(comp)[:6])}{'...' if len(comp) > 6 else ''}") + else: + print(" All rooms connected!") + + +def print_room(graph: dict[str, RoomInfo], room_name: str) -> None: + """Print exits for a specific room.""" + if room_name not in graph: + print(f"Room not found: {room_name}") + return + + room = graph[room_name] + print(f"Room: {room_name} (uid: {room.uid or 'unknown'})") + print(f" Exits ({len(room.transitions)}):") + for t in room.transitions: + target_room = _resolve_target_room(t, graph) + target_str = target_room or t.target_uid[:12] + print(f" {t.exit_node_name} → {target_str} [{t.label}] (polygon: {len(t.polygon)} pts)") + + +def find_and_print_path( + graph: dict[str, RoomInfo], start_room: str, end_room: str +) -> None: + """Find and print a path between two rooms.""" + steps = find_path(graph, start_room, end_room) + + if not steps: + print(f"No path from {start_room} to {end_room}") + components = get_connected_components(graph) + for comp in components: + if start_room in comp: + print(f" {start_room} is in component with {len(comp)} rooms") + break + for comp in components: + if end_room in comp: + print(f" {end_room} is in component with {len(comp)} rooms") + break + return + + print(f"Path from {start_room} → {end_room} ({len(steps)} step{'s' if len(steps) != 1 else ''}):") + for i, step in enumerate(steps): + coord_str = "" + cx, cy = step.viewport_centroid() + coord_str = f" (click at: {cx:.0f}, {cy:.0f})" + print( + f" {i + 1}. Click '{step.exit_node_name}' " + f"in {step.from_room} " + f"→ {step.to_room} [{step.label}]{coord_str}" + ) + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="KQ4 room graph builder and BFS pathfinder") + parser.add_argument("--from", dest="from_room", help="Starting room name (e.g., kq4_001_beach)") + parser.add_argument("--to", dest="to_room", help="Destination room name") + parser.add_argument("--room", help="Show exits for a specific room") + args = parser.parse_args() + + root = Path(__file__).resolve().parent.parent + scenes_dir = root / "scenes" + + if not scenes_dir.exists(): + print(f"ERROR: Scenes directory not found at {scenes_dir}", file=sys.stderr) + sys.exit(1) + + graph = build_graph(scenes_dir) + + if args.room: + print_room(graph, args.room) + elif args.from_room and args.to_room: + find_and_print_path(graph, args.from_room, args.to_room) + else: + print_graph_summary(graph) + + +if __name__ == "__main__": + main() diff --git a/tests/test_room_navigation.py b/tests/test_room_navigation.py new file mode 100644 index 0000000..bc24c89 --- /dev/null +++ b/tests/test_room_navigation.py @@ -0,0 +1,516 @@ +#!/usr/bin/env python3 +"""Tests for kq4 room navigation tools. + +Tests build_room_graph.py, mcp_client.py, and kq4_room_navigator.py offline logic. +MCP runtime integration requires a running game instance. + +Run: python tests/test_room_navigation.py +""" + +import json +import re +import socket +import sys +import threading +from pathlib import Path +from unittest import TestCase, main as test_main + +# Add project root to path +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "tools")) +sys.path.insert(0, str(PROJECT_ROOT / "scripts")) + +from build_room_graph import ( + NavigationStep, + RoomInfo, + TransitionInfo, + _resolve_target_room, + build_graph, + find_path, + find_uid_files, + get_connected_components, + parse_transitions, +) +from mcp_client import McpClient + + +# ─── Sample .tscn bodies for parse_transitions tests ───────────────── + +SCENE_WITH_BASIC_TRANSITIONS = """\ +[gd_scene format=3 uid="uid://abc123"] + +[node name="background" type="Node2D" unique_id=1] +script = ExtResource("1") + +[node name="kq4_010_forest_path" parent="." instance=ExtResource("4_xxx")] +position = Vector2(910, 542) +polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) +appear_at_node = "kq4_004_ogres_cottage" +target = "uid://bsog5s257pres" +label = "Forest Path" + +[node name="entrance" parent="kq4_010_forest_path" index="0"] +position = Vector2(118, 514) + +[node name="exit" parent="kq4_010_forest_path" index="1"] +position = Vector2(151, 615) + +[connection signal="interacted" from="kq4_010_forest_path" to="." method="_on_forest_path_interacted"] +""" + +SCENE_WITH_SCALED_TRANSITIONS = """\ +[gd_scene format=3 uid="uid://xyz789"] + +[node name="background" type="Node2D" unique_id=1] +script = ExtResource("1") + +[node name="kq4_049_ogres_cottage" parent="." instance=ExtResource("4_xxx")] +position = Vector2(500, 300) +scale = Vector2(0.783, 0.78) +polygon = PackedVector2Array(100, 200, 300, 400, 500, 600, 700, 800) +appear_at_node = "kq4_004_ogres_cottage_exterior" +target = "uid://c5h5n8dreoa8k" +label = "Door" + +[node name="entrance" parent="kq4_049_ogres_cottage" index="0"] +position = Vector2(100, 200) + +[node name="exit" parent="kq4_049_ogres_cottage" index="1"] +position = Vector2(300, 400) +""" + +SCENE_WITH_AND_STRING_APPEAR_AT = """\ +[gd_scene format=3 uid="uid://and123"] + +[node name="background" type="Node2D" unique_id=1] +script = ExtResource("1") + +[node name="kq4_005_forest_grove" parent="." instance=ExtResource("4_xxx")] +position = Vector2(1766, 74) +polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) +appear_at_node = &"kq4_004_ogres_cottage" +target = "uid://1c470jsfmdhxx" +label = "Forest Grove" + +[node name="entrance" parent="kq4_005_forest_grove" index="0"] +position = Vector2(24, 565) + +[node name="exit" parent="kq4_005_forest_grove" index="1"] +position = Vector2(293, 554) +""" + +SCENE_NO_TRANSITIONS = """\ +[gd_scene format=3 uid="uid://empty"] + +[node name="background" type="Node2D" unique_id=1] +script = ExtResource("1") + +[node name="some_setpiece" type="Polygon2D" parent="." unique_id=99] +polygon = PackedVector2Array(0, 0, 10, 10) +label = "Rock" +""" + + +# ─── Tests for parse_transitions ────────────────────────────────────── + + +class TestParseTransitions(TestCase): + """Unit tests for the .tscn transition parser.""" + + def test_basic_transition_parsed(self): + transitions = parse_transitions(SCENE_WITH_BASIC_TRANSITIONS, "test_room") + self.assertEqual(len(transitions), 1) + t = transitions[0] + self.assertEqual(t.exit_node_name, "kq4_010_forest_path") + self.assertEqual(t.target_uid, "uid://bsog5s257pres") + self.assertEqual(t.appear_at_node, "kq4_004_ogres_cottage") + self.assertEqual(t.label, "Forest Path") + self.assertEqual(len(t.polygon), 4) + self.assertEqual(t.position, (910.0, 542.0)) + self.assertEqual(t.scale, (1.0, 1.0)) + + def test_scaled_transition_parsed(self): + transitions = parse_transitions(SCENE_WITH_SCALED_TRANSITIONS, "test_room") + self.assertEqual(len(transitions), 1) + t = transitions[0] + self.assertEqual(t.exit_node_name, "kq4_049_ogres_cottage") + self.assertAlmostEqual(t.scale[0], 0.783) + self.assertAlmostEqual(t.scale[1], 0.78) + self.assertEqual(t.position, (500.0, 300.0)) + + def test_and_string_appear_at_node(self): + """GDScript &"string" syntax must also be recognized.""" + transitions = parse_transitions(SCENE_WITH_AND_STRING_APPEAR_AT, "test_room") + self.assertEqual(len(transitions), 1) + self.assertEqual(transitions[0].appear_at_node, "kq4_004_ogres_cottage") + + def test_no_transitions_in_scene(self): + transitions = parse_transitions(SCENE_NO_TRANSITIONS, "test_room") + self.assertEqual(len(transitions), 0) + + def test_polygon_centroid_basic(self): + """Test viewport_centroid with default position/scale.""" + t = TransitionInfo( + exit_node_name="x", + target_uid="uid://1", + appear_at_node="y", + label="X", + polygon=[(0, 0), (10, 0), (10, 10), (0, 10)], + ) + cx, cy = t.viewport_centroid() + self.assertAlmostEqual(cx, 5.0) + self.assertAlmostEqual(cy, 5.0) + + def test_polygon_centroid_with_position(self): + """Centroid accounts for node position.""" + t = TransitionInfo( + exit_node_name="x", + target_uid="uid://1", + appear_at_node="y", + label="X", + polygon=[(0, 0), (10, 0), (10, 10), (0, 10)], + position=(100.0, 200.0), + ) + cx, cy = t.viewport_centroid() + self.assertAlmostEqual(cx, 105.0) + self.assertAlmostEqual(cy, 205.0) + + def test_polygon_centroid_with_scale(self): + """Centroid accounts for both position and scale.""" + t = TransitionInfo( + exit_node_name="x", + target_uid="uid://1", + appear_at_node="y", + label="X", + polygon=[(0, 0), (10, 0), (10, 10), (0, 10)], + position=(100.0, 200.0), + scale=(0.8, 0.8), + ) + cx, cy = t.viewport_centroid() + # centroid_local = (5, 5), viewport = 0.8 * (100 + 5) = 84 + self.assertAlmostEqual(cx, 84.0) + self.assertAlmostEqual(cy, 164.0) + + def test_polygon_centroid_no_polygon(self): + """Returns position when polygon is empty.""" + t = TransitionInfo( + exit_node_name="x", + target_uid="uid://1", + appear_at_node="y", + label="X", + polygon=[], + position=(42.0, 99.0), + ) + cx, cy = t.viewport_centroid() + self.assertAlmostEqual(cx, 42.0) + self.assertAlmostEqual(cy, 99.0) + + def test_polygon_vertex_count(self): + """Verify polygon parsing produces correct number of vertices.""" + transitions = parse_transitions(SCENE_WITH_BASIC_TRANSITIONS, "test_room") + t = transitions[0] + # The example has 4 pairs: (-108,454), (-87,649), (376,658), (348,381) + self.assertEqual(len(t.polygon), 4) + self.assertAlmostEqual(t.polygon[0][0], -108.0) + self.assertAlmostEqual(t.polygon[0][1], 454.0) + + def test_transition_default_label_fallback(self): + """Label falls back to node_name when not specified.""" + body = '''\ +[node name="kq4_001_beach" parent="." instance=ExtResource("4")] +position = Vector2(100, 200) +polygon = PackedVector2Array(0, 0, 10, 10) +appear_at_node = "kq4_002_meadow" +target = "uid://test" +''' + transitions = parse_transitions(body, "room") + self.assertEqual(len(transitions), 1) + # No label attribute → should default to exit_node_name + self.assertEqual(transitions[0].label, "kq4_001_beach") + + +# ─── Tests for build_graph and find_path (using real project .tscn files) ── + + +class TestBuildGraph(TestCase): + """Integration tests using actual room scene files.""" + + @classmethod + def setUpClass(cls): + cls.scenes_dir = PROJECT_ROOT / "scenes" + if not cls.scenes_dir.exists(): + raise cls.skipTest("scenes/ directory not found") + cls.graph = build_graph(cls.scenes_dir) + + def test_graph_has_rooms(self): + # Should parse many rooms (project has ~96) + self.assertGreaterEqual(len(self.graph), 50) + + def test_known_rooms_exist(self): + expected = { + "kq4_003_fountain_pool", + "kq4_004_ogres_cottage", + "kq4_010_forest_path", + "kq4_005_forest_grove", + } + for room in expected: + self.assertIn(room, self.graph, f"{room} should be in graph") + + def test_room_has_exits(self): + # Room 004 has multiple exits + r = self.graph["kq4_004_ogres_cottage"] + self.assertGreaterEqual(len(r.transitions), 3) + + def test_transitions_have_positions(self): + """Transition nodes should have parsed positions.""" + r = self.graph["kq4_004_ogres_cottage"] + for t in r.transitions: + self.assertIsInstance(t.position, tuple) + self.assertEqual(len(t.position), 2) + + def test_transitions_have_scales(self): + """Transition nodes should have parsed scales (default to 1.0).""" + r = self.graph["kq4_004_ogres_cottage"] + for t in r.transitions: + self.assertIsInstance(t.scale, tuple) + self.assertEqual(len(t.scale), 2) + + def test_transitions_have_polygons(self): + """Transition nodes should have parsed polygon vertices.""" + r = self.graph["kq4_004_ogres_cottage"] + for t in r.transitions: + self.assertIsInstance(t.polygon, list) + self.assertGreater(len(t.polygon), 0, f"Empty polygon for {t.exit_node_name}") + + def test_rooms_have_uids(self): + """All rooms should have a UID from .tscn header.""" + for name, info in self.graph.items(): + self.assertIsNotNone(info.uid, f"{name} has no UID") + + def test_placeholder_template_excluded(self): + self.assertNotIn("kq4_placeholder_template", self.graph) + + +class TestFindPath(TestCase): + """BFS pathfinding tests against real project graph.""" + + @classmethod + def setUpClass(cls): + cls.scenes_dir = PROJECT_ROOT / "scenes" + if not cls.scenes_dir.exists(): + raise cls.skipTest("scenes/ directory not found") + cls.graph = build_graph(cls.scenes_dir) + + def test_path_1_hop(self): + """Room 003 → room 004 is directly connected.""" + steps = find_path(self.graph, "kq4_003_fountain_pool", "kq4_004_ogres_cottage") + self.assertIsNotNone(steps) + self.assertEqual(len(steps), 1) + self.assertEqual(steps[0].from_room, "kq4_003_fountain_pool") + self.assertEqual(steps[0].to_room, "kq4_004_ogres_cottage") + + def test_path_multi_hop(self): + """Room 003 → room 010 should be reachable (via room 004).""" + steps = find_path(self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path") + self.assertIsNotNone(steps) + # Path: 003 → 004 → 010 = 2 steps + self.assertGreaterEqual(len(steps), 1) + + def test_same_room_returns_empty(self): + """Path from a room to itself is trivially empty.""" + steps = find_path( + self.graph, "kq4_003_fountain_pool", "kq4_003_fountain_pool" + ) + self.assertEqual(steps, []) + + def test_disconnected_rooms(self): + """Rooms in different components should return None.""" + # Room 9 is its own isolated component (no bidirectional wiring) + if "kq4_009_shady_wooded_area" in self.graph: + steps = find_path( + self.graph, + "kq4_003_fountain_pool", + "kq4_009_shady_wooded_area", + ) + # This may or may not be connected depending on wiring state + # Just verify no crash and correct return type + self.assertIsNone(steps) if steps is None else True + + def test_invalid_start_room(self): + with self.assertRaises(ValueError): + find_path(self.graph, "nonexistent_room", "kq4_003_fountain_pool") + + def test_invalid_end_room(self): + with self.assertRaises(ValueError): + find_path(self.graph, "kq4_003_fountain_pool", "nonexistent_room") + + def test_steps_have_coordinates(self): + """NavigationStep from real graph should have usable coordinates.""" + steps = find_path( + self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path" + ) + if steps: + for step in steps: + cx, cy = step.viewport_centroid() + self.assertIsInstance(cx, float) + self.assertIsInstance(cy, float) + # Coordinates should be reasonable viewport values (0-2000 range typically) + self.assertGreater(cx, -500) + self.assertGreater(cy, -500) + + def test_bfs_finds_shortest_path(self): + """BFS guarantees shortest path. Verify no detour for 2-hop.""" + steps = find_path( + self.graph, "kq4_003_fountain_pool", "kq4_005_forest_grove" + ) + if steps: + # Should not take more than ~3 hops for nearby rooms + self.assertLessEqual(len(steps), 4) + + def test_steps_preserve_polygon(self): + """Step polygon should match source transition's polygon.""" + # Find a step and verify it has polygon vertices + steps = find_path( + self.graph, "kq4_003_fountain_pool", "kq4_010_forest_path" + ) + if steps: + for step in steps: + self.assertGreater( + len(step.polygon), 0, + f"Step {step.exit_node_name} has no polygon" + ) + + +class TestResolveTargetRoom(TestCase): + """Test UID → room name resolution.""" + + @classmethod + def setUpClass(cls): + cls.scenes_dir = PROJECT_ROOT / "scenes" + if not cls.scenes_dir.exists(): + raise cls.skipTest("scenes/ directory not found") + cls.graph = build_graph(cls.scenes_dir) + + def test_resolve_known_uid(self): + """A transition pointing to a known UID should resolve to a room name.""" + # Find any transition that resolves (target room exists in graph) + found_resolvable = False + for rinfo in self.graph.values(): + for t in rinfo.transitions: + resolved = _resolve_target_room(t, self.graph) + if resolved is not None: + found_resolvable = True + # Verify it resolves to a valid room name + self.assertIn(resolved, self.graph) + # At least some transitions should resolve (largest component has 36 rooms) + if not found_resolvable: + self.fail("No transitions could be resolved — graph may be in bad state") + + def test_resolve_unknown_uid(self): + """A transition with an unknown UID returns None.""" + t = TransitionInfo( + exit_node_name="test", + target_uid="uid://doesnotexist12345", + appear_at_node="something", + label="Test", + polygon=[], + ) + result = _resolve_target_room(t, self.graph) + self.assertIsNone(result) + + +class TestConnectedComponents(TestCase): + """Verify connected component analysis.""" + + @classmethod + def setUpClass(cls): + cls.scenes_dir = PROJECT_ROOT / "scenes" + if not cls.scenes_dir.exists(): + raise cls.skipTest("scenes/ directory not found") + cls.graph = build_graph(cls.scenes_dir) + cls.components = get_connected_components(cls.graph) + + def test_all_rooms_covered(self): + """Every room should appear in exactly one component.""" + all_covered = set() + for comp in self.components: + for r in comp: + self.assertNotIn(r, all_covered) + all_covered.update(comp) + all_graph_rooms = set(self.graph.keys()) + self.assertEqual(all_covered, all_graph_rooms) + + def test_most_connected(self): + """Largest component should have significant room count.""" + self.assertGreater(len(self.components[0]), 10) + + def test_component_contains_known_room(self): + """kq4_003_fountain_pool should be in the largest connected component.""" + largest = self.components[0] + self.assertIn("kq4_003_fountain_pool", largest) + + +class TestFindUIDFiles(TestCase): + """Test .uid file discovery.""" + + def test_find_uids(self): + uid_map = find_uid_files(PROJECT_ROOT) + # Should find at least some .uid files in the repo + self.assertIsInstance(uid_map, dict) + + def test_uid_format(self): + uid_map = find_uid_files(PROJECT_ROOT) + for uid_key in uid_map: + self.assertTrue( + uid_key.startswith("uid://"), f"Bad UID format: {uid_key}" + ) + + +# ─── MCP client tests (unit only, no live Godot required) ────────────── + +class TestMcpClient(TestCase): + """Unit tests for McpClient. Full integration requires running Godot on port 9090.""" + + def test_client_creation(self): + c = McpClient(host="127.0.0.1", port=9999) + self.assertEqual(c.host, "127.0.0.1") + self.assertEqual(c.port, 9999) + + def test_connect_required(self): + """send raises ConnectionError when called without connecting.""" + with self.assertRaises(ConnectionError): + McpClient(host="127.0.0.1", port=9999).eval_gdscript("return 42") + + +# ─── Navigator offline logic tests ────────────────────────────────── + + +class TestNavigatorOffline(TestCase): + """Test kq4_room_navigator.py without MCP.""" + + def test_navigator_import(self): + """Basic sanity: module loads without error.""" + from kq4_room_navigator import GDSCRIPT_GET_CURRENT_ROOM, NavigationError + + self.assertIsInstance(GDSCRIPT_GET_CURRENT_ROOM, str) + + def test_navigation_error_raised(self): + from kq4_room_navigator import NavigationError + + with self.assertRaises(NavigationError): + raise NavigationError("test") + + +if __name__ == "__main__": + loader = test_main(exit=False, verbosity=2) + result = loader.result + + failed = len(result.failures) + len(result.errors) + print(f"\n{'=' * 60}") + if failed: + print(f"FAILED: {failed} test(s) out of {result.testsRun}") + sys.exit(1) + else: + passed = result.testsRun + skipped = len(result.skipped) diff --git a/tools/kq4_room_navigator.py b/tools/kq4_room_navigator.py new file mode 100644 index 0000000..70b9e2e --- /dev/null +++ b/tools/kq4_room_navigator.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""KQ4 Room Navigator — BFS pathfinding + MCP runtime navigation. + +Uses file parsing to build a room adjacency graph, finds shortest paths via BFS, +then optionally uses the Godot MCP server (port 9090) to execute the navigation +step by step with click coordinates computed from TransitionPiece polygons. + +Usage: + # Just print the path plan: + python tools/kq4_room_navigator.py --from kq4_001_beach --to kq4_092_lolottes_throne_room + + # Navigate with MCP (game must be running): + python tools/kq4_room_navigator.py --from kq4_003_fountain_pool --to kq4_010_forest_path --navigate + + # Just print graph summary: + python tools/kq4_room_navigator.py --summary +""" + +import argparse +import json +import sys +import time +from pathlib import Path + +# Add project root to path for imports +_project_root = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_project_root)) +sys.path.insert(0, str(_project_root / "tools")) +sys.path.insert(0, str(_project_root / "scripts")) + +from build_room_graph import ( + NavigationStep, + build_graph, + find_path, + find_and_print_path, +) + + +BACKGROOM_PATH = "/root/Node2D/SceneViewport/background" + + +class NavigationError(Exception): + pass + + +def main() -> None: + parser = argparse.ArgumentParser( + description="KQ4 Room Navigator — plan and execute room-to-room navigation" + ) + parser.add_argument("--from", dest="from_room", help="Starting room name (e.g., kq4_003_fountain_pool)") + parser.add_argument("--to", dest="to_room", help="Destination room name") + parser.add_argument("--summary", action="store_true", help="Print room graph summary") + args = parser.parse_args() + + scenes_dir = _project_root / "scenes" + if not scenes_dir.exists(): + print(f"ERROR: Scenes directory not found at {scenes_dir}", file=sys.stderr) + sys.exit(1) + + graph = build_graph(scenes_dir) + + if args.summary: + from build_room_graph import print_graph_summary + + print_graph_summary(graph) + return + + if not (args.from_room and args.to_room): + parser.print_help() + return + + steps = find_path(graph, args.from_room, args.to_room) + if steps is None: + from build_room_graph import find_and_print_path + + find_and_print_path(graph, args.from_room, args.to_room) + sys.exit(1) + + # Print path plan + print(f"\nPath: {args.from_room} → {args.to_room} ({len(steps)} steps)\n") + for i, step in enumerate(steps): + cx, cy = step.viewport_centroid() + coord_note = f" (click at: {cx:.0f}, {cy:.0f})" if step.polygon else "" + print( + f" {i + 1}. Click '{step.exit_node_name}' " + f"in {step.from_room} → {step.to_room} [{step.label}]{coord_note}" + ) + +if __name__ == "__main__": + main()