From ec4fc8e756e2c3ab2bf9f165b25c2537dd0fd0aa Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 13:35:31 -0700 Subject: [PATCH 01/10] 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() From 9b5a7040c222237dabb8a5f8f90ab9654b2f7760 Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 15:54:24 -0700 Subject: [PATCH 02/10] Add get_current_room_name() helper to MainGame Provides a convenient way to identify the current room at runtime by extracting the room name from the scene script's resource path, rather than relying on node names (which are always 'background'). --- MainGame.gd | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/MainGame.gd b/MainGame.gd index 1aa521f..923e790 100644 --- a/MainGame.gd +++ b/MainGame.gd @@ -10,6 +10,14 @@ var is_cursor_locked: bool = false # When true, hourglass is shown and cursor c func get_scene() -> Scene: return $SceneViewport/background +func get_current_room_name() -> String: + var scene = get_scene() + if scene and scene.get_script(): + var script_path = scene.get_script().resource_path + if script_path.begins_with("res://scenes/"): + return script_path.trim_suffix(".gd").get_file() + return "" + # Called when the node enters the scene tree for the first time. func _ready(): # get_scene().connect("transitioned", Callable($SceneViewport/label, "_on_transitioned")) From 7a7d9e78db5f7c23581356c17c814e4933773207 Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 15:54:32 -0700 Subject: [PATCH 03/10] Prevent eval-induced debugger breaks from hanging the game When eval receives invalid GDScript, script.reload() triggers a debugger break that halts the game loop permanently (especially with LocalDebugger which doesn't respect skip_breakpoints). Two-layer defense: 1. On startup, attempt to disable debugger breaks via EngineDebugger.send_message (works for RemoteDebugger) 2. When debugger is unsafe (LocalDebugger), validate eval code in an external headless Godot process before executing it in-process --- scripts/mcp_interaction_server.gd | 94 +++++++++++++++++++++++++++---- 1 file changed, 83 insertions(+), 11 deletions(-) diff --git a/scripts/mcp_interaction_server.gd b/scripts/mcp_interaction_server.gd index 6ea8256..ca9de82 100644 --- a/scripts/mcp_interaction_server.gd +++ b/scripts/mcp_interaction_server.gd @@ -14,9 +14,18 @@ const BUSY_TIMEOUT: float = 30.0 var _key_map: Dictionary var _held_keys: Dictionary = {} +var _debugger_safe: bool = false + func _ready() -> void: - # Ensure MCP server keeps processing even when game is paused process_mode = Node.PROCESS_MODE_ALWAYS + if EngineDebugger.is_active(): + EngineDebugger.send_message("core:set_skip_breakpoints", [true]) + EngineDebugger.send_message("core:set_ignore_error_breaks", [true]) + _debugger_safe = EngineDebugger.is_skipping_breakpoints() + if _debugger_safe: + print("McpInteractionServer: Debugger breakpoints safely disabled") + else: + push_warning("McpInteractionServer: Could not disable debugger breakpoints (LocalDebugger?). Eval with invalid code may hang the game.") _init_key_map() _server = TCPServer.new() var err: int = _server.listen(PORT, "127.0.0.1") @@ -544,17 +553,18 @@ func _cmd_eval(params: Dictionary) -> void: _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 + var script_source: String = _build_eval_script(code) -func execute(): - var __result = null - __result = await _run() - return __result + if EngineDebugger.is_active() and not _debugger_safe: + var ext_validation: Dictionary = _validate_script_external(script_source) + if not ext_validation.get("valid", false): + _send_response({"error": "Script validation failed: %s" % ext_validation.get("error", "unknown error")}) + return -func _run(): -%s -""" % [_indent_code(code)] + var validation: Dictionary = _validate_script_source(script_source) + if not validation.get("valid", false): + _send_response({"error": "Script validation failed: %s" % validation.get("error", "unknown error"), "error_code": validation.get("error_code", -1)}) + return var script: GDScript = GDScript.new() script.source_code = script_source @@ -565,7 +575,6 @@ func _run(): 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) @@ -577,6 +586,69 @@ func _run(): _send_response({"success": true, "result": _variant_to_json(result)}) +func _build_eval_script(code: String) -> String: + return """extends Node + +func execute(): + var __result = null + __result = await _run() + return __result + +func _run(): +%s +""" % [_indent_code(code)] + + +func _validate_script_source(source: String) -> Dictionary: + var test_script: GDScript = GDScript.new() + test_script.source_code = source + var err: int = test_script.reload() + if err != OK: + var error_name: String = "" + match err: + ERR_PARSE_ERROR: + error_name = "Parse error" + ERR_COMPILATION_FAILED: + error_name = "Compilation failed" + _: + error_name = "Error code %d" % err + return {"valid": false, "error": error_name, "error_code": err} + return {"valid": true} + + +func _validate_script_external(source: String) -> Dictionary: + var temp_path: String = "user://_mcp_eval_validate_%d.gd" % Time.get_ticks_msec() + var file: FileAccess = FileAccess.open(temp_path, FileAccess.WRITE) + if file == null: + return {"valid": true} + file.store_string(source) + file.close() + + var godot_path: String = OS.get_executable_path() + var project_path: String = ProjectSettings.globalize_path("res://") + var global_temp: String = ProjectSettings.globalize_path(temp_path) + var output: Array = [] + var exit_code: int = OS.execute(godot_path, [ + "--headless", + "--check-only", + "--script", global_temp, + "--path", project_path + ], output) + + DirAccess.remove_absolute(global_temp) + + if exit_code != 0: + var error_detail: String = "" + for line in output: + error_detail += line + "\n" + error_detail = error_detail.strip_edges() + var msg: String = "Syntax error in eval code (external validation)" + if not error_detail.is_empty(): + msg += ": " + error_detail + return {"valid": false, "error": msg} + return {"valid": true} + + func _indent_code(code: String) -> String: var lines: PackedStringArray = code.split("\n") var indented: String = "" From 868b25299a328942fde19735a89eaec8453e80df Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 15:54:41 -0700 Subject: [PATCH 04/10] Update room navigator skill with mock_interact and room verification Replaces click-based navigation with mock_interact(0) on TransitionPiece nodes, adds get_current_room_name() verification pattern, and documents the MCP busy protocol for walk animation timeouts. --- .opencode/plans/kq4-room-navigator.md | 54 +++++- .opencode/skills/kq4-room-navigator/SKILL.md | 167 +++++++++++-------- 2 files changed, 141 insertions(+), 80 deletions(-) diff --git a/.opencode/plans/kq4-room-navigator.md b/.opencode/plans/kq4-room-navigator.md index dfe4a78..4a235f9 100644 --- a/.opencode/plans/kq4-room-navigator.md +++ b/.opencode/plans/kq4-room-navigator.md @@ -79,13 +79,14 @@ python tools/kq4_room_navigator.py --from kq4_004_ogres_cottage --to kq4_092_lol 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 + a. Verify starting room: `eval("return get_node('/root/Node2D').get_current_room_name()")` returns the current room's node name (e.g., `"kq4_003_fountain_pool"`). This convenience function on MainGame simplifies verification compared to traversing the scene tree. + b. `find_nodes_by_class(class_name="TransitionPiece")` — discover all transition pieces in current scene + c. Match the exit node name from our path → get runtime position + polygon + d. Compute click coordinate: centroid of the TransitionPiece's `polygon`, transformed to viewport coordinates + e. `click(x, y)` — trigger the transition + f. Wait for transition animation (2-3s via `wait` command or poll-loop) + g. **Verify arrival**: `eval("return get_node('/root/Node2D').get_current_room_name()")` to confirm we've entered the expected room. Compare against the `to_room` from our BFS path. Returns empty string if no scene loaded. + h. 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 @@ -118,12 +119,24 @@ 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` +- **Room verification helper** (`MainGame.get_current_room_name()`): + + The root node (`/root/Node2D`, script: `MainGame.gd`) provides a convenience function for testing: + + ```gdscript + # Via MCP eval — returns node name like "kq4_003_fountain_pool", or "" if no scene loaded + return get_node("/root/Node2D").get_current_room_name() + ``` + + Use this after each transition click to verify you arrived at the expected room. Compare against the `to_room` field from your BFS path. + - **Manual MCP workflow** (for step-by-step agent control): - Start Godot: `godot --path .` or run exported binary + - Check current room: `{"command": "eval", "params": {"code": "return get_node('/root/Node2D').get_current_room_name()"}}` → returns `"kq4_003_fountain_pool"` style string - 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` + - Verify current room: `{"command": "eval", "params": {"code": "return get_node('/root/Node2D').get_current_room_name()"}}` (returns node name like `"kq4_010_forest_path"`). Alternatively use screenshot for visual confirmation. - **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 @@ -134,6 +147,31 @@ The skill guide documents: ``` [1. build_room_graph.py] ────────┐ ├── [2. kq4_room_navigator.py] ─── [3. SKILL.md] + +### Using MainGame.get_current_room_name() for Room Verification + +The `MainGame.gd` script (attached to `/root/Node2D`) provides a convenience function: + +```gdscript +# Returns the node name of the current scene, e.g., "kq4_003_fountain_pool" +func get_current_room_name() -> String: + var scene = get_scene() + if scene: + return scene.name + return "" +``` + +**Via MCP eval for runtime verification**: +```bash +curl http://localhost:9090 -d '{"command": "eval", "params": {"code": "return get_node(\"/root/Node2D\").get_current_room_name()"}}' +``` + +Returns the room node name string (e.g., `"kq4_003_fountain_pool"`), or empty string if no scene is loaded. This is more reliable than traversing the scene tree manually since it uses the same `$SceneViewport/background` reference that MainGame's own logic uses. + +**When to use**: +- After clicking a transition, call to confirm arrival at expected room in `to_room` +- Before navigation starts, verify you're actually in the intended `from_room` +- Compare against BFS path nodes during automated step-by-step execution ``` Tasks run sequentially. Task 3 documents the completed system. diff --git a/.opencode/skills/kq4-room-navigator/SKILL.md b/.opencode/skills/kq4-room-navigator/SKILL.md index c14e86c..405e546 100644 --- a/.opencode/skills/kq4-room-navigator/SKILL.md +++ b/.opencode/skills/kq4-room-navigator/SKILL.md @@ -1,6 +1,8 @@ --- +name: kq4-room-navigator +description: Navigate between KQ4 rooms using the Godot MCP server. BFS pathfinds through room transition graph, then the agent navigates 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". +--- -## 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 @@ -8,7 +10,7 @@ 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). +The room transition system uses TransitionPiece nodes: each has an `exit_node_name` (destination), a polygon, and connects rooms typically bidirectionally. Clicking a TransitionPiece triggers a 3-step animation (walk → fade/swap scenes → walk to entrance in new room). ## When to Use @@ -16,7 +18,6 @@ The room transition system uses TransitionPiece nodes: each has an `exit_node_na - 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 @@ -30,9 +31,19 @@ Room identification at runtime: - 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 +### Getting the current room name at runtime + +The canonical way is via `MainGame.get_current_room_name()`: + +```json +{"command": "eval", "params": {"code": "get_tree().root.get_node('Node2D').get_current_room_name()"}} +``` + +Returns a string like `"kq4_010_forest_path"`. The implementation extracts the filename from the scene script's resource path (`script_path.trim_suffix(".gd").get_file()`), NOT the node name (which is always `"background"`). + ## Quick Start -### Step 1 Make a plan: +### Step 1 — Make a plan: ```bash python tools/kq4_room_navigator.py --from kq4_003_fountain_pool --to kq4_011_enchanted_grove @@ -47,73 +58,68 @@ Path: kq4_003_fountain_pool → kq4_011_enchanted_grove (3 steps) 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. +The click coordinates are not used — the `exit_node_name` is what matters. -Here's how to proceed. +### Step 2 — Launch and navigate -1. start the game (godot --path . &) - - ```bash - # Wait for "McpInteractionServer: Listening on 127.0.0.1:9090" in console +1. Use the Godot MCP to start the game (`godot_run_project`) +2. Poll room name to verify starting room: ``` + get_tree().root.get_node('Node2D').get_current_room_name() + ``` +3. For each step, call **`mock_interact(0)`** on the TransitionPiece node (see exact method name below) +4. Wait and poll until `get_current_room_name()` returns the expected destination +5. Repeat for next step -2. Verify current room matches the starting point +## Detailed Navigation Protocol -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 +### Identify current room ```json -{"command": "find_nodes_by_class", "params": {"class_name": "TransitionPiece"}} +{"command": "eval", "params": {"code": "get_tree().root.get_node('Node2D').get_current_room_name()"}} ``` -Returns all TransitionPieces in the active scene with `.name`, `.label`, `.target` properties. +Returns `"kq4_0xx_room_name"`, confirming both MCP connectivity and which room you're in. -For rooms without scripts, identify via transition piece labels or target UIDs cross-referenced with `scripts/build_room_graph.py --room ` output. +### Discover available exits from current room -### Simulate interactions - -Use eval to find the centroid of a TransitionPiece's polygon in viewport space: +Use the `set-piece` group to find all interactive polygons (TransitionPieces are automatically added): ```json -{ - "command": "eval", - "params": { - "code": " get_tree().root.get_node_or_null('Node2D/SceneViewport/background/kq4_010_forest_path').mock_interaction(0)" - } -} +{"command": "get_nodes_in_group", "params": {"group": "set-piece"}} ``` -Interactions match ActionState.Action (LOOK/WALK/ITEM/TALK) +Filter results for node names starting with `kq4_0` — these are transition exits. -### Waiting after interaction +### Trigger a room transition -```json -{"command": "wait", "params": {"frames": 60}} +**Critical: The method name is `mock_interact`, NOT `mock_interaction`.** + +Use `godot_game_call_method` to call it directly on the node path: + +``` +Call method mock_interact(0) on /root/Node2D/SceneViewport/background/ ``` -Transition animation takes \~1-2 seconds (fade-out + scene swap + fade-in). Wait 30+ frames before checking room change. +Argument `0` = ActionState.Action.WALK (1=LOOK, 2=TOUCH, 3=TALK). + +### Waiting — MCP busy protocol + +**IMPORTANT**: Walk animations block the McpInteractionServer for ~30 seconds. During this time: +- `eval`, `wait`, `call_method` all fail with "timed out after 30s" +- The server has a built-in `_busy` flag auto-reset after ~30s ("_busy flag stuck for 30.Xs, force-resetting") + +**Robust polling pattern**: +1. Call `mock_interact(0)` on the target node (returns immediately, animation starts) +2. Try `wait(frames=30)` then poll room name +3. If both timeout (~30s elapsed), retry the room name poll once more +4. The server will auto-reset and the next poll will succeed + +Expected timing per transition: **~45-75 seconds total** (walk ~15s, MCP block ~30s, fade-in ~5s, confirm ~2s) + +### Finding transitions by script handler + +In room `.gd` files, handlers like `_on_ogres_cottage_interacted()` reference the TransitionPiece node (`$kq4_004_ogres_cottage.default_script(self)`). The handler's parameter name matches the node name you need to call `mock_interact(0)` on. ## Room Graph Structure @@ -122,49 +128,66 @@ 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) +- Two additional 36-room components that are disconnected due to UID mismatches -Use `python scripts/build_room_graph.py --room ` to check a room's available exits. +Use `python scripts/build_room_graph.py --from A --to B` to find a path. If "no path" is returned but rooms feel like they should connect, the cause is likely a stale UID (see Common Issues). + +### UID mismatch example + +Room `kq4_018_cemetery` has UID `uid://b3fjmiaribbrl`, but nearby rooms point to it with stale UIDs (`uid://35amqvpjgpf2x`). Runtime navigation works fine (Transitions use file paths), but the graph can't find the connection. ## 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. | +| `mock_interaction` method not found | Wrong method name | Use **`mock_interact(0)`** — no trailing "tion" | +| MCP commands time out during transition | Walk animation blocks server (~30s) | Wait and retry; server auto-resets after ~30s | +| "No path" between adjacent rooms at graph level | Target UID in source `.tscn` doesn't match destination room's `.uid` file | Runtime still works. Fix the target uid in the source tscn or update the destination room's .uid | +| `get_current_room_name()` returns `"background"` | Old implementation used `scene.name` | Updated to use `script_path.trim_suffix(".gd").get_file()` | + +## Verified Navigation Example + +Successfully tested navigating from kq4_003_fountain_pool to kq4_018_cemetery via 5 transitions: + +``` +kq4_003_fountain_pool → mock_interact(kq4_004_ogres_cottage) → kq4_004_ogres_cottage ✓ +kq4_004_ogres_cottage → mock_interact(kq4_010_forest_path) → kq4_010_forest_path ✓ +kq4_010_forest_path → mock_interact(kq4_011_enchanted_grove) → kq4_011_enchanted_grove ✓ +kq4_011_enchanted_grove → mock_interact(kq4_017_spooky_house_exterior) → kq4_017_spooky_house_exterior ✓ +kq4_017_spooky_house_exterior → mock_interact(kq4_018_cemetery) → kq4_018_cemetery ✓ +``` ## API Reference -### build_room_graph.py +### build_room_graph.py module ```python -from build_room_graph import build_graph, find_path, NavigationStep +from pathlib import Path +from scripts.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 +graph = build_graph(Path('scenes')) # RoomInfo dict keyed by room name +steps = find_path(graph, "kq4_003_fountain_pool", "kq4_018_cemetery") # List[NavigationStep] or None +step.from_room # Source room name +step.exit_node_name # TransitionPiece node to call mock_interact(0) on +step.to_room # Destination room name +step.label # Human-readable label (e.g., "Ogre's Cottage") ``` ### kq4_room_navigator.py CLI ```bash -python tools/kq4_room_navigator.py [--from ROOM] [--to ROOM] [summary] +python tools/kq4_room_navigator.py --from ROOM --to ROOM +# For graph summary: +python tools/kq4_room_navigator.py summary ``` ## Key Files | File | Purpose | | --- | --- | +| `MainGame.gd` | Root game node; `get_current_room_name()` returns room from script path | | `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) | +| `TransitionPiece.gd` | Exit nodes; `mock_interact(action)` triggers default_script() | | `SetPiece_.gd` | Base interactive polygon class (class_name SetPiece) | -| | | +| `.opencode/skills/kq4-room-creator/SKILL.md` | Related: creating new rooms with transitions | From 2479e4c7842c7af4a55693212dae88acced8a6ef Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 15:55:00 -0700 Subject: [PATCH 05/10] Update scene UIDs to match regenerated .uid files Transition target UIDs in .tscn files updated to match the current .uid files for each room, fixing broken scene transitions caused by stale UIDs. --- scenes/kq4_001_beach/kq4_001_beach.tscn | 2 +- scenes/kq4_001_beach/kq4_001_beach.tscn.uid | 2 +- scenes/kq4_002_meadow/kq4_002_meadow.tscn | 6 +++--- scenes/kq4_002_meadow/kq4_002_meadow.tscn.uid | 2 +- scenes/kq4_003_fountain_pool/kq4_003_fountain_pool.tscn | 6 +++--- .../kq4_003_fountain_pool/kq4_003_fountain_pool.tscn.uid | 2 +- scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn | 4 ++-- .../kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn.uid | 2 +- scenes/kq4_005_forest_grove/kq4_005_forest_grove.tscn | 4 ++-- .../kq4_007_fishermans_shack.tscn | 6 +++--- .../kq4_008_back_of_fishermans_shack.tscn | 4 ++-- .../kq4_008_back_of_fishermans_shack.tscn.uid | 2 +- .../kq4_009_shady_wooded_area.tscn | 8 ++++---- .../kq4_009_shady_wooded_area.tscn.uid | 2 +- scenes/kq4_010_forest_path/kq4_010_forest_path.tscn | 8 ++++---- scenes/kq4_010_forest_path/kq4_010_forest_path.tscn.uid | 2 +- .../kq4_011_enchanted_grove/kq4_011_enchanted_grove.tscn | 4 ++-- scenes/kq4_013_beach/kq4_013_beach.tscn.uid | 2 +- scenes/kq4_014_green_meadow/kq4_014_green_meadow.tscn | 8 ++++---- scenes/kq4_015_frog_pond/kq4_015_frog_pond.tscn | 6 +++--- scenes/kq4_015_frog_pond/kq4_015_frog_pond.tscn.uid | 2 +- scenes/kq4_016_graveyard/kq4_016_graveyard.tscn | 8 ++++---- scenes/kq4_016_graveyard/kq4_016_graveyard.tscn.uid | 2 +- .../kq4_017_spooky_house_exterior.tscn | 4 ++-- .../kq4_017_spooky_house_exterior.tscn.uid | 2 +- scenes/kq4_018_cemetery/kq4_018_cemetery.tscn | 2 +- scenes/kq4_018_cemetery/kq4_018_cemetery.tscn.uid | 2 +- scenes/kq4_019_coastal_cliffs/kq4_019_coastal_cliffs.tscn | 4 ++-- scenes/kq4_020_meadow/kq4_020_meadow.tscn | 4 ++-- scenes/kq4_020_meadow/kq4_020_meadow.tscn.uid | 2 +- .../kq4_021_bridge_over_stream.tscn | 8 ++++---- .../kq4_021_bridge_over_stream.tscn.uid | 2 +- scenes/kq4_022_gnomes_cottage/kq4_022_gnomes_cottage.tscn | 6 +++--- .../kq4_022_gnomes_cottage.tscn.uid | 2 +- .../kq4_023_forest_path_with_cottage.tscn | 6 +++--- .../kq4_024_waterfall_and_pool.tscn | 2 +- .../kq4_025_beach_at_river_delta.tscn | 4 ++-- scenes/kq4_026_river_meadow/kq4_026_river_meadow.tscn | 6 +++--- scenes/kq4_026_river_meadow/kq4_026_river_meadow.tscn.uid | 2 +- scenes/kq4_027_forest_path/kq4_027_forest_path.tscn | 8 ++++---- scenes/kq4_027_forest_path/kq4_027_forest_path.tscn.uid | 2 +- scenes/kq4_028_mine_entrance/kq4_028_mine_entrance.tscn | 8 ++++---- .../kq4_028_mine_entrance/kq4_028_mine_entrance.tscn.uid | 2 +- scenes/kq4_029_dense_forest/kq4_029_dense_forest.tscn | 2 +- scenes/kq4_029_dense_forest/kq4_029_dense_forest.tscn.uid | 2 +- scenes/kq4_030_mountain_pass/kq4_030_mountain_pass.tscn | 2 +- scenes/kq4_031_open_ocean/kq4_031_open_ocean.tscn | 4 ++-- scenes/kq4_031_open_ocean/kq4_031_open_ocean.tscn.uid | 1 + scenes/kq4_049_ogres_cottage/kq4_049_ogres_cottage.tscn | 2 +- .../kq4_049_ogres_cottage/kq4_049_ogres_cottage.tscn.uid | 2 +- scenes/kq4_051_ogres_closet/kq4_051_ogres_closet.tscn | 2 +- .../kq4_053_seven_dwarfs_bedroom.tscn | 2 +- .../kq4_054_seven_dwarfs_cottage.tscn | 2 +- .../kq4_054_seven_dwarfs_cottage.tscn.uid | 2 +- .../kq4_055_seven_dwarfs_diamond_mine.tscn | 2 +- scenes/kq4_059_baby_nursery/kq4_059_baby_nursery.tscn | 2 +- scenes/kq4_059_baby_nursery/kq4_059_baby_nursery.tscn.uid | 2 +- scenes/kq4_060_bedroom/kq4_060_bedroom.tscn | 2 +- scenes/kq4_060_bedroom/kq4_060_bedroom.tscn.uid | 2 +- scenes/kq4_062_bedroom/kq4_062_bedroom.tscn | 4 ++-- scenes/kq4_062_bedroom/kq4_062_bedroom.tscn.uid | 2 +- .../kq4_064_old_dining_room/kq4_064_old_dining_room.tscn | 4 ++-- .../kq4_064_old_dining_room.tscn.uid | 2 +- scenes/kq4_065_old_kitchen/kq4_065_old_kitchen.tscn | 2 +- scenes/kq4_065_old_kitchen/kq4_065_old_kitchen.tscn.uid | 2 +- scenes/kq4_067_the_parlor/kq4_067_the_parlor.tscn | 2 +- scenes/kq4_068_the_foyer/kq4_068_the_foyer.tscn | 6 +++--- scenes/kq4_068_the_foyer/kq4_068_the_foyer.tscn.uid | 2 +- 68 files changed, 116 insertions(+), 115 deletions(-) create mode 100644 scenes/kq4_031_open_ocean/kq4_031_open_ocean.tscn.uid diff --git a/scenes/kq4_001_beach/kq4_001_beach.tscn b/scenes/kq4_001_beach/kq4_001_beach.tscn index 2a0da32..093a9f4 100644 --- a/scenes/kq4_001_beach/kq4_001_beach.tscn +++ b/scenes/kq4_001_beach/kq4_001_beach.tscn @@ -54,7 +54,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_001_beach" -target = "uid://1489d4oh9twtu" +target = "uid://dxs1tr5yvmoba" label = "Meadow" [node name="entrance" parent="kq4_002_meadow" index="0"] diff --git a/scenes/kq4_001_beach/kq4_001_beach.tscn.uid b/scenes/kq4_001_beach/kq4_001_beach.tscn.uid index 7451f41..c34b90c 100644 --- a/scenes/kq4_001_beach/kq4_001_beach.tscn.uid +++ b/scenes/kq4_001_beach/kq4_001_beach.tscn.uid @@ -1 +1 @@ -uid://1rwfkejhz94hp +uid://dlg6010ym2uw4 diff --git a/scenes/kq4_002_meadow/kq4_002_meadow.tscn b/scenes/kq4_002_meadow/kq4_002_meadow.tscn index e97fe5d..4227ccc 100644 --- a/scenes/kq4_002_meadow/kq4_002_meadow.tscn +++ b/scenes/kq4_002_meadow/kq4_002_meadow.tscn @@ -58,7 +58,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-79, 729, -86, 873, 261, 904, 249, 694) appear_at_node = "kq4_002_meadow" -target = "uid://151dbn9bybiwx" +target = "uid://dyk4rcqsk3aed" label = "Fountain Pool" [node name="entrance" parent="kq4_003_fountain_pool" index="0"] @@ -71,7 +71,7 @@ position = Vector2(227, 790) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_002_meadow" -target = "uid://bncmzju9ibkv" +target = "uid://bncm0jvaibkv" label = "Back of Fisherman's Shack" [node name="entrance" parent="kq4_008_back_of_fishermans_shack" index="0"] @@ -83,7 +83,7 @@ position = Vector2(151, 615) [node name="kq4_001_beach" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_002_meadow" -target = "uid://1rwfkejhz94hp" +target = "uid://dlg6010ym2uw4" label = "Beach" [node name="entrance" parent="kq4_001_beach" index="0"] diff --git a/scenes/kq4_002_meadow/kq4_002_meadow.tscn.uid b/scenes/kq4_002_meadow/kq4_002_meadow.tscn.uid index 1c2b6ae..d840431 100644 --- a/scenes/kq4_002_meadow/kq4_002_meadow.tscn.uid +++ b/scenes/kq4_002_meadow/kq4_002_meadow.tscn.uid @@ -1 +1 @@ -uid://1489d4oh9twtu +uid://dxs1tr5yvmoba diff --git a/scenes/kq4_003_fountain_pool/kq4_003_fountain_pool.tscn b/scenes/kq4_003_fountain_pool/kq4_003_fountain_pool.tscn index 7af903d..2269927 100644 --- a/scenes/kq4_003_fountain_pool/kq4_003_fountain_pool.tscn +++ b/scenes/kq4_003_fountain_pool/kq4_003_fountain_pool.tscn @@ -48,7 +48,7 @@ position = Vector2(194, 819) position = Vector2(-150, 100) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_003_fountain_pool" -target = "uid://1489d4oh9twtu" +target = "uid://dxs1tr5yvmoba" label = "Meadow" [node name="entrance" parent="kq4_002_meadow" index="0"] @@ -75,7 +75,7 @@ position = Vector2(66, 71) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_003_fountain_pool" -target = "uid://1hkplw2a78b1y" +target = "uid://da4h2ljrt02ie" label = "Shady Wooded Forest" [node name="entrance" parent="kq4_009_shady_wooded_area" index="0"] @@ -89,7 +89,7 @@ position = Vector2(500, 100) color = Color(1, 1, 1, 1) polygon = PackedVector2Array(349, 278, 398, 417, 593, 411, 650, 277) appear_at_node = "kq4_003_fountain_pool" -target = "uid://1fpyosj18xls7" +target = "uid://c8aq5g1juqdam" label = "Forest Path" [node name="entrance" parent="kq4_027_forest_path" index="0"] diff --git a/scenes/kq4_003_fountain_pool/kq4_003_fountain_pool.tscn.uid b/scenes/kq4_003_fountain_pool/kq4_003_fountain_pool.tscn.uid index 6a8d0ca..b0e9b26 100644 --- a/scenes/kq4_003_fountain_pool/kq4_003_fountain_pool.tscn.uid +++ b/scenes/kq4_003_fountain_pool/kq4_003_fountain_pool.tscn.uid @@ -1 +1 @@ -uid://151dbn9bybiwx \ No newline at end of file +uid://dyk4rcqsk3aed diff --git a/scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn b/scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn index 674bad7..4ee3912 100644 --- a/scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn +++ b/scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn @@ -43,7 +43,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_004_ogres_cottage" -target = "uid://qkcwifq2lb9m" +target = "uid://qkcwifq2lcam" label = "Mine Entrance" [node name="entrance" parent="kq4_028_mine_entrance" index="0"] @@ -57,7 +57,7 @@ position = Vector2(5, 888) color = Color(1, 1, 1, 1) polygon = PackedVector2Array(-85, -86, -65, 128, 264, 167, 260, -78) appear_at_node = "kq4_004_ogres_cottage" -target = "uid://151dbn9bybiwx" +target = "uid://dyk4rcqsk3aed" label = "Fountain Pool" [node name="entrance" parent="kq4_003_fountain_pool" index="0"] diff --git a/scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn.uid b/scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn.uid index d835963..976123c 100644 --- a/scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn.uid +++ b/scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn.uid @@ -1 +1 @@ -uid://1nxmm3b1kcdm1 \ No newline at end of file +uid://dhie3qsi5333g diff --git a/scenes/kq4_005_forest_grove/kq4_005_forest_grove.tscn b/scenes/kq4_005_forest_grove/kq4_005_forest_grove.tscn index 4113c9c..2889566 100644 --- a/scenes/kq4_005_forest_grove/kq4_005_forest_grove.tscn +++ b/scenes/kq4_005_forest_grove/kq4_005_forest_grove.tscn @@ -46,7 +46,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_005_forest_grove" -target = "uid://1sfzaldfq5kn1" +target = "uid://dlyrp8twdxb4g" label = "Dense Forest" [node name="entrance" parent="kq4_029_dense_forest" index="0"] @@ -84,7 +84,7 @@ position = Vector2(151, 615) [node name="kq4_004_ogres_cottage" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_005_forest_grove" -target = "uid://1nxmm3b1kcdm1" +target = "uid://dhie3qsi5333g" label = "Ogre's Cottage" [node name="entrance" parent="kq4_004_ogres_cottage" index="0"] diff --git a/scenes/kq4_007_fishermans_shack/kq4_007_fishermans_shack.tscn b/scenes/kq4_007_fishermans_shack/kq4_007_fishermans_shack.tscn index f3cca92..c38574a 100644 --- a/scenes/kq4_007_fishermans_shack/kq4_007_fishermans_shack.tscn +++ b/scenes/kq4_007_fishermans_shack/kq4_007_fishermans_shack.tscn @@ -46,7 +46,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_007_fishermans_shack" -target = "uid://1rwfkejhz94hp" +target = "uid://dlg6010ym2uw4" label = "Beach" [node name="entrance" parent="kq4_001_beach" index="0"] @@ -59,7 +59,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_007_fishermans_shack" -target = "uid://bncmzju9ibkv" +target = "uid://bncm0jvaibkv" label = "Back of Fisherman's Shack" [node name="entrance" parent="kq4_008_back_of_fishermans_shack" index="0"] @@ -72,7 +72,7 @@ position = Vector2(293, 554) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_007_fishermans_shack" -target = "uid://2bqawc9w4uu59" +target = "uid://d4a2d0rfqnmmo" label = "Beach" [node name="entrance" parent="kq4_013_beach" index="0"] diff --git a/scenes/kq4_008_back_of_fishermans_shack/kq4_008_back_of_fishermans_shack.tscn b/scenes/kq4_008_back_of_fishermans_shack/kq4_008_back_of_fishermans_shack.tscn index f8fe04a..4cfb2f7 100644 --- a/scenes/kq4_008_back_of_fishermans_shack/kq4_008_back_of_fishermans_shack.tscn +++ b/scenes/kq4_008_back_of_fishermans_shack/kq4_008_back_of_fishermans_shack.tscn @@ -45,7 +45,7 @@ z_index = 4 position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_008_back_of_fishermans_shack" -target = "uid://1hkplw2a78b1y" +target = "uid://da4h2ljrt02ie" label = "Shady Wooded Area" [node name="entrance" parent="kq4_009_shady_wooded_area" index="0"] @@ -59,7 +59,7 @@ z_index = 2 position = Vector2(910, -213) polygon = PackedVector2Array(-631, 454, -539, 610, 376, 658, 769, 610, 748, 475, 348, 381, -594, 362) appear_at_node = "kq4_008_back_of_fishermans_shack" -target = "uid://1489d4oh9twtu" +target = "uid://dxs1tr5yvmoba" label = "Meadow" [node name="entrance" parent="kq4_002_meadow" index="0"] diff --git a/scenes/kq4_008_back_of_fishermans_shack/kq4_008_back_of_fishermans_shack.tscn.uid b/scenes/kq4_008_back_of_fishermans_shack/kq4_008_back_of_fishermans_shack.tscn.uid index 3624083..510fbac 100644 --- a/scenes/kq4_008_back_of_fishermans_shack/kq4_008_back_of_fishermans_shack.tscn.uid +++ b/scenes/kq4_008_back_of_fishermans_shack/kq4_008_back_of_fishermans_shack.tscn.uid @@ -1 +1 @@ -uid://bncmzju9ibkv +uid://bncm0jvaibkv diff --git a/scenes/kq4_009_shady_wooded_area/kq4_009_shady_wooded_area.tscn b/scenes/kq4_009_shady_wooded_area/kq4_009_shady_wooded_area.tscn index 8d92e18..1dce323 100644 --- a/scenes/kq4_009_shady_wooded_area/kq4_009_shady_wooded_area.tscn +++ b/scenes/kq4_009_shady_wooded_area/kq4_009_shady_wooded_area.tscn @@ -45,7 +45,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_009_shady_wooded_area" -target = "uid://151dbn9bybiwx" +target = "uid://dyk4rcqsk3aed" label = "Fountain Pool" [node name="entrance" parent="kq4_003_fountain_pool" index="0"] @@ -58,7 +58,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_009_shady_wooded_area" -target = "uid://3ujj97iw54vo5" +target = "uid://bsog5s257pres" label = "Forest Path" [node name="entrance" parent="kq4_010_forest_path" index="0"] @@ -71,7 +71,7 @@ position = Vector2(293, 554) position = Vector2(-200, 100) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_009_shady_wooded_area" -target = "uid://bncmzju9ibkv" +target = "uid://bncm0jvaibkv" label = "Back of Fisherman's Shack" [node name="entrance" parent="kq4_008_back_of_fishermans_shack" index="0"] @@ -84,7 +84,7 @@ position = Vector2(100, 480) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_009_shady_wooded_area" -target = "uid://2zga29mwl2ced" +target = "uid://xk6xu65nm620" label = "Frog Pond" [node name="entrance" parent="kq4_015_frog_pond" index="0"] diff --git a/scenes/kq4_009_shady_wooded_area/kq4_009_shady_wooded_area.tscn.uid b/scenes/kq4_009_shady_wooded_area/kq4_009_shady_wooded_area.tscn.uid index 36b31ff..8fda729 100644 --- a/scenes/kq4_009_shady_wooded_area/kq4_009_shady_wooded_area.tscn.uid +++ b/scenes/kq4_009_shady_wooded_area/kq4_009_shady_wooded_area.tscn.uid @@ -1 +1 @@ -uid://1hkplw2a78b1y \ No newline at end of file +uid://da4h2ljrt02ie diff --git a/scenes/kq4_010_forest_path/kq4_010_forest_path.tscn b/scenes/kq4_010_forest_path/kq4_010_forest_path.tscn index 2476fc4..af2129e 100644 --- a/scenes/kq4_010_forest_path/kq4_010_forest_path.tscn +++ b/scenes/kq4_010_forest_path/kq4_010_forest_path.tscn @@ -44,7 +44,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_010_forest_path" -target = "uid://151dbn9bybiwx" +target = "uid://dyk4rcqsk3aed" label = "Fountain Pool" [node name="entrance" parent="kq4_003_fountain_pool" index="0"] @@ -57,7 +57,7 @@ position = Vector2(174, 519) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_010_forest_path" -target = "uid://1nxmm3b1kcdm1" +target = "uid://dhie3qsi5333g" label = "Ogre's Cottage" [node name="entrance" parent="kq4_004_ogres_cottage" index="0"] @@ -83,7 +83,7 @@ position = Vector2(293, 554) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_010_forest_path" -target = "uid://27b2k6gky3afg" +target = "uid://5gygr0s1n433" label = "Graveyard" [node name="entrance" parent="kq4_016_graveyard" index="0"] @@ -96,7 +96,7 @@ position = Vector2(151, 615) position = Vector2(-150, 100) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_010_forest_path" -target = "uid://1hkplw2a78b1y" +target = "uid://da4h2ljrt02ie" label = "Shady Wooded Area" [node name="entrance" parent="kq4_009_shady_wooded_area" index="0"] diff --git a/scenes/kq4_010_forest_path/kq4_010_forest_path.tscn.uid b/scenes/kq4_010_forest_path/kq4_010_forest_path.tscn.uid index 975a3db..d997eac 100644 --- a/scenes/kq4_010_forest_path/kq4_010_forest_path.tscn.uid +++ b/scenes/kq4_010_forest_path/kq4_010_forest_path.tscn.uid @@ -1 +1 @@ -uid://3ujj97iw54vo5 \ No newline at end of file +uid://bsog5s257pres diff --git a/scenes/kq4_011_enchanted_grove/kq4_011_enchanted_grove.tscn b/scenes/kq4_011_enchanted_grove/kq4_011_enchanted_grove.tscn index b644266..2ea287c 100644 --- a/scenes/kq4_011_enchanted_grove/kq4_011_enchanted_grove.tscn +++ b/scenes/kq4_011_enchanted_grove/kq4_011_enchanted_grove.tscn @@ -69,7 +69,7 @@ position = Vector2(293, 554) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_011_enchanted_grove" -target = "uid://1kz9yo5f1tpc6" +target = "uid://dek2gdmwnmgsl" label = "Spooky House Exterior" [node name="entrance" parent="kq4_017_spooky_house_exterior" index="0"] @@ -81,7 +81,7 @@ position = Vector2(151, 615) [node name="kq4_010_forest_path" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_011_enchanted_grove" -target = "uid://3ujj97iw54vo5" +target = "uid://bsog5s257pres" label = "Forest Path" [node name="entrance" parent="kq4_010_forest_path" index="0"] diff --git a/scenes/kq4_013_beach/kq4_013_beach.tscn.uid b/scenes/kq4_013_beach/kq4_013_beach.tscn.uid index fe4d2b9..df07a25 100644 --- a/scenes/kq4_013_beach/kq4_013_beach.tscn.uid +++ b/scenes/kq4_013_beach/kq4_013_beach.tscn.uid @@ -1 +1 @@ -uid://2bqawc9w4uu59 +uid://d4a2d0rfqnmmo diff --git a/scenes/kq4_014_green_meadow/kq4_014_green_meadow.tscn b/scenes/kq4_014_green_meadow/kq4_014_green_meadow.tscn index 50f85d2..427051d 100644 --- a/scenes/kq4_014_green_meadow/kq4_014_green_meadow.tscn +++ b/scenes/kq4_014_green_meadow/kq4_014_green_meadow.tscn @@ -46,7 +46,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_014_green_meadow" -target = "uid://bncmzju9ibkv" +target = "uid://bncm0jvaibkv" label = "Back of Fisherman's Shack" [node name="entrance" parent="kq4_008_back_of_fishermans_shack" index="0"] @@ -59,7 +59,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_014_green_meadow" -target = "uid://2zga29mwl2ced" +target = "uid://xk6xu65nm620" label = "Frog Pond" [node name="entrance" parent="kq4_015_frog_pond" index="0"] @@ -72,7 +72,7 @@ position = Vector2(293, 554) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_014_green_meadow" -target = "uid://2yy1t1lic39gp" +target = "uid://w4xpm5qeo45d" label = "Meadow" [node name="entrance" parent="kq4_020_meadow" index="0"] @@ -84,7 +84,7 @@ position = Vector2(151, 615) [node name="kq4_013_beach" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_014_green_meadow" -target = "uid://2bqawc9w4uu59" +target = "uid://d4a2d0rfqnmmo" label = "Beach" [node name="entrance" parent="kq4_013_beach" index="0"] diff --git a/scenes/kq4_015_frog_pond/kq4_015_frog_pond.tscn b/scenes/kq4_015_frog_pond/kq4_015_frog_pond.tscn index 0a56269..e346b29 100644 --- a/scenes/kq4_015_frog_pond/kq4_015_frog_pond.tscn +++ b/scenes/kq4_015_frog_pond/kq4_015_frog_pond.tscn @@ -45,7 +45,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_015_frog_pond" -target = "uid://1hkplw2a78b1y" +target = "uid://da4h2ljrt02ie" label = "Shady Wooded Area" [node name="entrance" parent="kq4_009_shady_wooded_area" index="0"] @@ -58,7 +58,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_015_frog_pond" -target = "uid://27b2k6gky3afg" +target = "uid://5gygr0s1n433" label = "Graveyard" [node name="entrance" parent="kq4_016_graveyard" index="0"] @@ -84,7 +84,7 @@ position = Vector2(300, 554) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_015_frog_pond" -target = "uid://3uxipzjekijqc" +target = "uid://bs3fll3ml3ffy" label = "Bridge over Stream" [node name="entrance" parent="kq4_021_bridge_over_stream" index="0"] diff --git a/scenes/kq4_015_frog_pond/kq4_015_frog_pond.tscn.uid b/scenes/kq4_015_frog_pond/kq4_015_frog_pond.tscn.uid index 4a60a5e..c6891a3 100644 --- a/scenes/kq4_015_frog_pond/kq4_015_frog_pond.tscn.uid +++ b/scenes/kq4_015_frog_pond/kq4_015_frog_pond.tscn.uid @@ -1 +1 @@ -uid://2zga29mwl2ced +uid://xk6xu65nm620 diff --git a/scenes/kq4_016_graveyard/kq4_016_graveyard.tscn b/scenes/kq4_016_graveyard/kq4_016_graveyard.tscn index 875a7e5..d44d65c 100644 --- a/scenes/kq4_016_graveyard/kq4_016_graveyard.tscn +++ b/scenes/kq4_016_graveyard/kq4_016_graveyard.tscn @@ -44,7 +44,7 @@ position = Vector2(194, 819) position = Vector2(-200, 100) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_016_graveyard" -target = "uid://2zga29mwl2ced" +target = "uid://xk6xu65nm620" label = "Frog Pond" [node name="entrance" parent="kq4_015_frog_pond" index="0"] @@ -57,7 +57,7 @@ position = Vector2(100, 480) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_016_graveyard" -target = "uid://3ujj97iw54vo5" +target = "uid://bsog5s257pres" label = "Forest Path" [node name="entrance" parent="kq4_010_forest_path" index="0"] @@ -70,7 +70,7 @@ position = Vector2(174, 519) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_016_graveyard" -target = "uid://3oq4x3exoimdb" +target = "uid://bmv1tox6p3h1x" label = "Gnomes Cottage" [node name="entrance" parent="kq4_022_gnomes_cottage" index="0"] @@ -83,7 +83,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_016_graveyard" -target = "uid://1kz9yo5f1tpc6" +target = "uid://dek2gdmwnmgsl" label = "Spooky House Exterior" [node name="entrance" parent="kq4_017_spooky_house_exterior" index="0"] diff --git a/scenes/kq4_016_graveyard/kq4_016_graveyard.tscn.uid b/scenes/kq4_016_graveyard/kq4_016_graveyard.tscn.uid index 5307ba0..c58b89c 100644 --- a/scenes/kq4_016_graveyard/kq4_016_graveyard.tscn.uid +++ b/scenes/kq4_016_graveyard/kq4_016_graveyard.tscn.uid @@ -1 +1 @@ -uid://27b2k6gky3afg \ No newline at end of file +uid://5gygr0s1n433 diff --git a/scenes/kq4_017_spooky_house_exterior/kq4_017_spooky_house_exterior.tscn b/scenes/kq4_017_spooky_house_exterior/kq4_017_spooky_house_exterior.tscn index f1132fa..888137e 100644 --- a/scenes/kq4_017_spooky_house_exterior/kq4_017_spooky_house_exterior.tscn +++ b/scenes/kq4_017_spooky_house_exterior/kq4_017_spooky_house_exterior.tscn @@ -56,7 +56,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_017_spooky_house_exterior" -target = "uid://35amqvpjgpf2x" +target = "uid://b3fjmiaribbrl" label = "Cemetery" [node name="entrance" parent="kq4_018_cemetery" index="0"] @@ -81,7 +81,7 @@ position = Vector2(151, 615) [node name="kq4_016_graveyard" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_017_spooky_house_exterior" -target = "uid://27b2k6gky3afg" +target = "uid://5gygr0s1n433" label = "Graveyard" [node name="entrance" parent="kq4_016_graveyard" index="0"] diff --git a/scenes/kq4_017_spooky_house_exterior/kq4_017_spooky_house_exterior.tscn.uid b/scenes/kq4_017_spooky_house_exterior/kq4_017_spooky_house_exterior.tscn.uid index f8261f5..42db75d 100644 --- a/scenes/kq4_017_spooky_house_exterior/kq4_017_spooky_house_exterior.tscn.uid +++ b/scenes/kq4_017_spooky_house_exterior/kq4_017_spooky_house_exterior.tscn.uid @@ -1 +1 @@ -uid://1kz9yo5f1tpc6 +uid://dek2gdmwnmgsl diff --git a/scenes/kq4_018_cemetery/kq4_018_cemetery.tscn b/scenes/kq4_018_cemetery/kq4_018_cemetery.tscn index fec8f0a..81ee26d 100644 --- a/scenes/kq4_018_cemetery/kq4_018_cemetery.tscn +++ b/scenes/kq4_018_cemetery/kq4_018_cemetery.tscn @@ -67,7 +67,7 @@ position = Vector2(151, 615) [node name="kq4_017_spooky_house_exterior" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_018_cemetery" -target = "uid://1kz9yo5f1tpc6" +target = "uid://dek2gdmwnmgsl" label = "Spooky House Exterior" [node name="entrance" parent="kq4_017_spooky_house_exterior" index="0"] diff --git a/scenes/kq4_018_cemetery/kq4_018_cemetery.tscn.uid b/scenes/kq4_018_cemetery/kq4_018_cemetery.tscn.uid index 2a8b190..2980eb0 100644 --- a/scenes/kq4_018_cemetery/kq4_018_cemetery.tscn.uid +++ b/scenes/kq4_018_cemetery/kq4_018_cemetery.tscn.uid @@ -1 +1 @@ -uid://35amqvpjgpf2x +uid://b3fjmiaribbrl diff --git a/scenes/kq4_019_coastal_cliffs/kq4_019_coastal_cliffs.tscn b/scenes/kq4_019_coastal_cliffs/kq4_019_coastal_cliffs.tscn index f9c7e30..c671807 100644 --- a/scenes/kq4_019_coastal_cliffs/kq4_019_coastal_cliffs.tscn +++ b/scenes/kq4_019_coastal_cliffs/kq4_019_coastal_cliffs.tscn @@ -22,7 +22,7 @@ navigation_polygon = SubResource("NavigationPolygon_mt6rs") [node name="kq4_020_meadow" parent="." index="5" instance=ExtResource("4_8xjvi")] polygon = PackedVector2Array(1821, 506, 1756, 1110, 2122, 1104, 2067, 511) appear_at_node = "kq4_019_coastal_cliffs" -target = "uid://2yy1t1lic39gp" +target = "uid://w4xpm5qeo45d" label = "Meadow" [node name="entrance" parent="kq4_020_meadow" index="0"] @@ -46,7 +46,7 @@ position = Vector2(1538, 1274) [node name="kq4_013_beach" parent="." index="7" instance=ExtResource("4_8xjvi")] polygon = PackedVector2Array(1200, 100, 1300, 200, 1500, 200, 1600, 100) appear_at_node = "kq4_019_coastal_cliffs" -target = "uid://2bqawc9w4uu59" +target = "uid://d4a2d0rfqnmmo" label = "Beach" [node name="entrance" parent="kq4_013_beach" index="0"] diff --git a/scenes/kq4_020_meadow/kq4_020_meadow.tscn b/scenes/kq4_020_meadow/kq4_020_meadow.tscn index 710d48d..c7bacfd 100644 --- a/scenes/kq4_020_meadow/kq4_020_meadow.tscn +++ b/scenes/kq4_020_meadow/kq4_020_meadow.tscn @@ -59,7 +59,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_020_meadow" -target = "uid://3uxipzjekijqc" +target = "uid://bs3fll3ml3ffy" label = "Bridge over Stream" [node name="entrance" parent="kq4_021_bridge_over_stream" index="0"] @@ -84,7 +84,7 @@ position = Vector2(-64, 534) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_020_meadow" -target = "uid://10p7miv0a14l7" +target = "uid://dtay26ehvtu2m" label = "River Meadow" [node name="entrance" parent="kq4_026_river_meadow" index="0"] diff --git a/scenes/kq4_020_meadow/kq4_020_meadow.tscn.uid b/scenes/kq4_020_meadow/kq4_020_meadow.tscn.uid index 2fb5e8d..a87c7a3 100644 --- a/scenes/kq4_020_meadow/kq4_020_meadow.tscn.uid +++ b/scenes/kq4_020_meadow/kq4_020_meadow.tscn.uid @@ -1 +1 @@ -uid://2yy1t1lic39gp \ No newline at end of file +uid://w4xpm5qeo45d diff --git a/scenes/kq4_021_bridge_over_stream/kq4_021_bridge_over_stream.tscn b/scenes/kq4_021_bridge_over_stream/kq4_021_bridge_over_stream.tscn index 4e64105..fb69400 100644 --- a/scenes/kq4_021_bridge_over_stream/kq4_021_bridge_over_stream.tscn +++ b/scenes/kq4_021_bridge_over_stream/kq4_021_bridge_over_stream.tscn @@ -44,7 +44,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_021_bridge_over_stream" -target = "uid://2zga29mwl2ced" +target = "uid://xk6xu65nm620" label = "Frog Pond" [node name="entrance" parent="kq4_015_frog_pond" index="0"] @@ -57,7 +57,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_021_bridge_over_stream" -target = "uid://3oq4x3exoimdb" +target = "uid://bmv1tox6p3h1x" label = "Gnome's Cottage" [node name="entrance" parent="kq4_022_gnomes_cottage" index="0"] @@ -70,7 +70,7 @@ position = Vector2(293, 554) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_021_bridge_over_stream" -target = "uid://1fpyosj18xls7" +target = "uid://c8aq5g1juqdam" label = "Forest Path" [node name="entrance" parent="kq4_027_forest_path" index="0"] @@ -83,7 +83,7 @@ position = Vector2(151, 615) position = Vector2(-200, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_021_bridge_over_stream" -target = "uid://2yy1t1lic39gp" +target = "uid://w4xpm5qeo45d" label = "Meadow" [node name="entrance" parent="kq4_020_meadow" index="0"] diff --git a/scenes/kq4_021_bridge_over_stream/kq4_021_bridge_over_stream.tscn.uid b/scenes/kq4_021_bridge_over_stream/kq4_021_bridge_over_stream.tscn.uid index bb0460b..0801408 100644 --- a/scenes/kq4_021_bridge_over_stream/kq4_021_bridge_over_stream.tscn.uid +++ b/scenes/kq4_021_bridge_over_stream/kq4_021_bridge_over_stream.tscn.uid @@ -1 +1 @@ -uid://3uxipzjekijqc +uid://bs3fll3ml3ffy diff --git a/scenes/kq4_022_gnomes_cottage/kq4_022_gnomes_cottage.tscn b/scenes/kq4_022_gnomes_cottage/kq4_022_gnomes_cottage.tscn index 468a8b5..425940d 100644 --- a/scenes/kq4_022_gnomes_cottage/kq4_022_gnomes_cottage.tscn +++ b/scenes/kq4_022_gnomes_cottage/kq4_022_gnomes_cottage.tscn @@ -43,7 +43,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_022_gnomes_cottage" -target = "uid://27b2k6gky3afg" +target = "uid://5gygr0s1n433" label = "Graveyard" [node name="entrance" parent="kq4_016_graveyard" index="0"] @@ -56,7 +56,7 @@ position = Vector2(174, 519) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_022_gnomes_cottage" -target = "uid://qkcwifq2lb9m" +target = "uid://qkcwifq2lcam" label = "South Exit" [node name="entrance" parent="kq4_028_mine_entrance" index="0"] @@ -81,7 +81,7 @@ position = Vector2(293, 554) [node name="kq4_021_bridge_over_stream" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_022_gnomes_cottage" -target = "uid://3uxipzjekijqc" +target = "uid://bs3fll3ml3ffy" label = "West Exit" [node name="entrance" parent="kq4_021_bridge_over_stream" index="0"] diff --git a/scenes/kq4_022_gnomes_cottage/kq4_022_gnomes_cottage.tscn.uid b/scenes/kq4_022_gnomes_cottage/kq4_022_gnomes_cottage.tscn.uid index f74829f..7161132 100644 --- a/scenes/kq4_022_gnomes_cottage/kq4_022_gnomes_cottage.tscn.uid +++ b/scenes/kq4_022_gnomes_cottage/kq4_022_gnomes_cottage.tscn.uid @@ -1 +1 @@ -uid://3oq4x3exoimdb \ No newline at end of file +uid://bmv1tox6p3h1x diff --git a/scenes/kq4_023_forest_path_with_cottage/kq4_023_forest_path_with_cottage.tscn b/scenes/kq4_023_forest_path_with_cottage/kq4_023_forest_path_with_cottage.tscn index 8859c61..f70d955 100644 --- a/scenes/kq4_023_forest_path_with_cottage/kq4_023_forest_path_with_cottage.tscn +++ b/scenes/kq4_023_forest_path_with_cottage/kq4_023_forest_path_with_cottage.tscn @@ -43,7 +43,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_023_forest_path_with_cottage" -target = "uid://1kz9yo5f1tpc6" +target = "uid://dek2gdmwnmgsl" label = "Spooky House Exterior" [node name="entrance" parent="kq4_017_spooky_house_exterior" index="0"] @@ -69,7 +69,7 @@ position = Vector2(293, 554) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_023_forest_path_with_cottage" -target = "uid://1sfzaldfq5kn1" +target = "uid://dlyrp8twdxb4g" label = "Dense Forest" [node name="entrance" parent="kq4_029_dense_forest" index="0"] @@ -81,7 +81,7 @@ position = Vector2(151, 615) [node name="kq4_022_gnomes_cottage" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_023_forest_path_with_cottage" -target = "uid://3oq4x3exoimdb" +target = "uid://bmv1tox6p3h1x" label = "Gnome's Cottage" [node name="entrance" parent="kq4_022_gnomes_cottage" index="0"] diff --git a/scenes/kq4_024_waterfall_and_pool/kq4_024_waterfall_and_pool.tscn b/scenes/kq4_024_waterfall_and_pool/kq4_024_waterfall_and_pool.tscn index da0c352..2b42d99 100644 --- a/scenes/kq4_024_waterfall_and_pool/kq4_024_waterfall_and_pool.tscn +++ b/scenes/kq4_024_waterfall_and_pool/kq4_024_waterfall_and_pool.tscn @@ -44,7 +44,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_024_waterfall_and_pool" -target = "uid://35amqvpjgpf2x" +target = "uid://b3fjmiaribbrl" label = "Cemetery" [node name="entrance" parent="kq4_018_cemetery" index="0"] diff --git a/scenes/kq4_025_beach_at_river_delta/kq4_025_beach_at_river_delta.tscn b/scenes/kq4_025_beach_at_river_delta/kq4_025_beach_at_river_delta.tscn index c4cb4b3..f593fa5 100644 --- a/scenes/kq4_025_beach_at_river_delta/kq4_025_beach_at_river_delta.tscn +++ b/scenes/kq4_025_beach_at_river_delta/kq4_025_beach_at_river_delta.tscn @@ -65,7 +65,7 @@ position = Vector2(1535, 302) position = Vector2(1800, 400) polygon = PackedVector2Array(1700, 300, 1900, 300, 1900, 500, 1700, 500) appear_at_node = "kq4_025_beach_at_river_delta" -target = "uid://10p7miv0a14l7" +target = "uid://dtay26ehvtu2m" label = "River Meadow" [node name="entrance" parent="kq4_026_river_meadow" index="0"] @@ -91,7 +91,7 @@ position = Vector2(50, 350) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_025_beach_at_river_delta" -target = "uid://1rwfkejhz94hp" +target = "uid://dlg6010ym2uw4" label = "Beach" [node name="entrance" parent="kq4_001_beach" index="0"] diff --git a/scenes/kq4_026_river_meadow/kq4_026_river_meadow.tscn b/scenes/kq4_026_river_meadow/kq4_026_river_meadow.tscn index 3c6ae75..10f2e76 100644 --- a/scenes/kq4_026_river_meadow/kq4_026_river_meadow.tscn +++ b/scenes/kq4_026_river_meadow/kq4_026_river_meadow.tscn @@ -41,7 +41,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_026_river_meadow" -target = "uid://2yy1t1lic39gp" +target = "uid://w4xpm5qeo45d" label = "Meadow" priority = 1 @@ -55,7 +55,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_026_river_meadow" -target = "uid://1fpyosj18xls7" +target = "uid://c8aq5g1juqdam" label = "Forest Path" [node name="entrance" parent="kq4_027_forest_path" index="0"] @@ -68,7 +68,7 @@ position = Vector2(293, 554) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_026_river_meadow" -target = "uid://1489d4oh9twtu" +target = "uid://dxs1tr5yvmoba" label = "Meadow" [node name="entrance" parent="kq4_002_meadow" index="0"] diff --git a/scenes/kq4_026_river_meadow/kq4_026_river_meadow.tscn.uid b/scenes/kq4_026_river_meadow/kq4_026_river_meadow.tscn.uid index b29c2bc..cd3b477 100644 --- a/scenes/kq4_026_river_meadow/kq4_026_river_meadow.tscn.uid +++ b/scenes/kq4_026_river_meadow/kq4_026_river_meadow.tscn.uid @@ -1 +1 @@ -uid://10p7miv0a14l7 +uid://dtay26ehvtu2m diff --git a/scenes/kq4_027_forest_path/kq4_027_forest_path.tscn b/scenes/kq4_027_forest_path/kq4_027_forest_path.tscn index 14d4a80..d7e6d7f 100644 --- a/scenes/kq4_027_forest_path/kq4_027_forest_path.tscn +++ b/scenes/kq4_027_forest_path/kq4_027_forest_path.tscn @@ -43,7 +43,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_027_forest_path" -target = "uid://3uxipzjekijqc" +target = "uid://bs3fll3ml3ffy" label = "Bridge over Stream" [node name="entrance" parent="kq4_021_bridge_over_stream" index="0"] @@ -56,7 +56,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_027_forest_path" -target = "uid://qkcwifq2lb9m" +target = "uid://qkcwifq2lcam" label = "Mine Entrance" [node name="entrance" parent="kq4_028_mine_entrance" index="0"] @@ -69,7 +69,7 @@ position = Vector2(293, 554) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_027_forest_path" -target = "uid://151dbn9bybiwx" +target = "uid://dyk4rcqsk3aed" label = "Fountain Pool" [node name="entrance" parent="kq4_003_fountain_pool" index="0"] @@ -81,7 +81,7 @@ position = Vector2(151, 615) [node name="kq4_026_river_meadow" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_027_forest_path" -target = "uid://10p7miv0a14l7" +target = "uid://dtay26ehvtu2m" label = "River Meadow" [node name="entrance" parent="kq4_026_river_meadow" index="0"] diff --git a/scenes/kq4_027_forest_path/kq4_027_forest_path.tscn.uid b/scenes/kq4_027_forest_path/kq4_027_forest_path.tscn.uid index 10ee8aa..47b946a 100644 --- a/scenes/kq4_027_forest_path/kq4_027_forest_path.tscn.uid +++ b/scenes/kq4_027_forest_path/kq4_027_forest_path.tscn.uid @@ -1 +1 @@ -uid://1fpyosj18xls7 +uid://c8aq5g1juqdam diff --git a/scenes/kq4_028_mine_entrance/kq4_028_mine_entrance.tscn b/scenes/kq4_028_mine_entrance/kq4_028_mine_entrance.tscn index 648fa94..24a49c4 100644 --- a/scenes/kq4_028_mine_entrance/kq4_028_mine_entrance.tscn +++ b/scenes/kq4_028_mine_entrance/kq4_028_mine_entrance.tscn @@ -43,7 +43,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_028_mine_entrance" -target = "uid://3oq4x3exoimdb" +target = "uid://bmv1tox6p3h1x" label = "Gnome's Cottage" [node name="entrance" parent="kq4_022_gnomes_cottage" index="0"] @@ -56,7 +56,7 @@ position = Vector2(174, 519) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_028_mine_entrance" -target = "uid://1sfzaldfq5kn1" +target = "uid://dlyrp8twdxb4g" label = "Dense Forest" [node name="entrance" parent="kq4_029_dense_forest" index="0"] @@ -69,7 +69,7 @@ position = Vector2(293, 554) position = Vector2(910, 542) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_028_mine_entrance" -target = "uid://1nxmm3b1kcdm1" +target = "uid://dhie3qsi5333g" label = "Ogre's Cottage" [node name="entrance" parent="kq4_004_ogres_cottage" index="0"] @@ -81,7 +81,7 @@ position = Vector2(151, 615) [node name="kq4_027_forest_path" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_028_mine_entrance" -target = "uid://1fpyosj18xls7" +target = "uid://c8aq5g1juqdam" label = "Forest Path" [node name="entrance" parent="kq4_027_forest_path" index="0"] diff --git a/scenes/kq4_028_mine_entrance/kq4_028_mine_entrance.tscn.uid b/scenes/kq4_028_mine_entrance/kq4_028_mine_entrance.tscn.uid index d085512..aadaf1a 100644 --- a/scenes/kq4_028_mine_entrance/kq4_028_mine_entrance.tscn.uid +++ b/scenes/kq4_028_mine_entrance/kq4_028_mine_entrance.tscn.uid @@ -1 +1 @@ -uid://qkcwifq2lb9m \ No newline at end of file +uid://qkcwifq2lcam diff --git a/scenes/kq4_029_dense_forest/kq4_029_dense_forest.tscn b/scenes/kq4_029_dense_forest/kq4_029_dense_forest.tscn index 67850ac..d0e586f 100644 --- a/scenes/kq4_029_dense_forest/kq4_029_dense_forest.tscn +++ b/scenes/kq4_029_dense_forest/kq4_029_dense_forest.tscn @@ -81,7 +81,7 @@ position = Vector2(151, 615) [node name="kq4_028_mine_entrance" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_029_dense_forest" -target = "uid://qkcwifq2lb9m" +target = "uid://qkcwifq2lcam" label = "Mine Entrance" [node name="entrance" parent="kq4_028_mine_entrance" index="0"] diff --git a/scenes/kq4_029_dense_forest/kq4_029_dense_forest.tscn.uid b/scenes/kq4_029_dense_forest/kq4_029_dense_forest.tscn.uid index 93d9d0a..ddb2edd 100644 --- a/scenes/kq4_029_dense_forest/kq4_029_dense_forest.tscn.uid +++ b/scenes/kq4_029_dense_forest/kq4_029_dense_forest.tscn.uid @@ -1 +1 @@ -uid://1sfzaldfq5kn1 +uid://dlyrp8twdxb4g diff --git a/scenes/kq4_030_mountain_pass/kq4_030_mountain_pass.tscn b/scenes/kq4_030_mountain_pass/kq4_030_mountain_pass.tscn index 5117fa9..c42c949 100644 --- a/scenes/kq4_030_mountain_pass/kq4_030_mountain_pass.tscn +++ b/scenes/kq4_030_mountain_pass/kq4_030_mountain_pass.tscn @@ -67,7 +67,7 @@ position = Vector2(293, 554) [node name="kq4_029_dense_forest" parent="." unique_id=1117747814 instance=ExtResource("4_abc")] polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_030_mountain_pass" -target = "uid://1sfzaldfq5kn1" +target = "uid://dlyrp8twdxb4g" label = "Dense Forest" [node name="entrance" parent="kq4_029_dense_forest" index="0"] diff --git a/scenes/kq4_031_open_ocean/kq4_031_open_ocean.tscn b/scenes/kq4_031_open_ocean/kq4_031_open_ocean.tscn index 7d3a059..c7c6bac 100644 --- a/scenes/kq4_031_open_ocean/kq4_031_open_ocean.tscn +++ b/scenes/kq4_031_open_ocean/kq4_031_open_ocean.tscn @@ -43,7 +43,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_031_open_ocean" -target = "uid://1rwfkejhz94hp" +target = "uid://dlg6010ym2uw4" label = "Beach" [node name="entrance" parent="kq4_001_beach" index="0"] @@ -82,7 +82,7 @@ position = Vector2(174, 519) position = Vector2(1500, 200) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_031_open_ocean" -target = "uid://2bqawc9w4uu59" +target = "uid://d4a2d0rfqnmmo" label = "Beach" [node name="entrance" parent="kq4_013_beach" index="0"] diff --git a/scenes/kq4_031_open_ocean/kq4_031_open_ocean.tscn.uid b/scenes/kq4_031_open_ocean/kq4_031_open_ocean.tscn.uid new file mode 100644 index 0000000..7b34330 --- /dev/null +++ b/scenes/kq4_031_open_ocean/kq4_031_open_ocean.tscn.uid @@ -0,0 +1 @@ +uid://2f7c49hpkducc diff --git a/scenes/kq4_049_ogres_cottage/kq4_049_ogres_cottage.tscn b/scenes/kq4_049_ogres_cottage/kq4_049_ogres_cottage.tscn index e1771a8..1fd0a0e 100644 --- a/scenes/kq4_049_ogres_cottage/kq4_049_ogres_cottage.tscn +++ b/scenes/kq4_049_ogres_cottage/kq4_049_ogres_cottage.tscn @@ -41,7 +41,7 @@ position = Vector2(194, 819) position = Vector2(910, -213) polygon = PackedVector2Array(-703, 472, -696, 1033, -462, 953, -471, 562) appear_at_node = "kq4_049_ogres_cottage" -target = "uid://1nxmm3b1kcdm1" +target = "uid://dhie3qsi5333g" label = "Ogre's Cottage Exterior" [node name="entrance" parent="kq4_004_ogres_cottage_exterior" index="0"] diff --git a/scenes/kq4_049_ogres_cottage/kq4_049_ogres_cottage.tscn.uid b/scenes/kq4_049_ogres_cottage/kq4_049_ogres_cottage.tscn.uid index a82111a..dafaf9e 100644 --- a/scenes/kq4_049_ogres_cottage/kq4_049_ogres_cottage.tscn.uid +++ b/scenes/kq4_049_ogres_cottage/kq4_049_ogres_cottage.tscn.uid @@ -1 +1 @@ -uid://1cxd7kvarvjr5 +uid://c5h5n8dreoa8k diff --git a/scenes/kq4_051_ogres_closet/kq4_051_ogres_closet.tscn b/scenes/kq4_051_ogres_closet/kq4_051_ogres_closet.tscn index 4d9fe7b..409a64d 100644 --- a/scenes/kq4_051_ogres_closet/kq4_051_ogres_closet.tscn +++ b/scenes/kq4_051_ogres_closet/kq4_051_ogres_closet.tscn @@ -41,7 +41,7 @@ position = Vector2(194, 819) position = Vector2(1766, 74) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_051_ogres_closet" -target = "uid://1cxd7kvarvjr5" +target = "uid://c5h5n8dreoa8k" label = "Ogre's Cottage" [node name="entrance" parent="kq4_049_ogres_cottage" index="0"] diff --git a/scenes/kq4_053_seven_dwarfs_bedroom/kq4_053_seven_dwarfs_bedroom.tscn b/scenes/kq4_053_seven_dwarfs_bedroom/kq4_053_seven_dwarfs_bedroom.tscn index 68a63ad..5bafad4 100644 --- a/scenes/kq4_053_seven_dwarfs_bedroom/kq4_053_seven_dwarfs_bedroom.tscn +++ b/scenes/kq4_053_seven_dwarfs_bedroom/kq4_053_seven_dwarfs_bedroom.tscn @@ -39,7 +39,7 @@ position = Vector2(200, 900) position = Vector2(1766, 600) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_053_seven_dwarfs_bedroom" -target = "uid://3opp6zygwkh7x" +target = "uid://bmum2mjox5dwl" label = "054 Seven Dwarfs Cottage" [node name="entrance" parent="kq4_054_seven_dwarfs_cottage" index="0"] diff --git a/scenes/kq4_054_seven_dwarfs_cottage/kq4_054_seven_dwarfs_cottage.tscn b/scenes/kq4_054_seven_dwarfs_cottage/kq4_054_seven_dwarfs_cottage.tscn index 075d27c..c31613e 100644 --- a/scenes/kq4_054_seven_dwarfs_cottage/kq4_054_seven_dwarfs_cottage.tscn +++ b/scenes/kq4_054_seven_dwarfs_cottage/kq4_054_seven_dwarfs_cottage.tscn @@ -52,7 +52,7 @@ position = Vector2(188, 628) position = Vector2(801, 597) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_054_seven_dwarfs_cottage" -target = "uid://3oq4x3exoimdb" +target = "uid://bmv1tox6p3h1x" label = "022 Gnomes Cottage" [node name="entrance" parent="kq4_022_gnomes_cottage" index="0"] diff --git a/scenes/kq4_054_seven_dwarfs_cottage/kq4_054_seven_dwarfs_cottage.tscn.uid b/scenes/kq4_054_seven_dwarfs_cottage/kq4_054_seven_dwarfs_cottage.tscn.uid index 091a1a0..b7ce4ff 100644 --- a/scenes/kq4_054_seven_dwarfs_cottage/kq4_054_seven_dwarfs_cottage.tscn.uid +++ b/scenes/kq4_054_seven_dwarfs_cottage/kq4_054_seven_dwarfs_cottage.tscn.uid @@ -1 +1 @@ -uid://3opp6zygwkh7x +uid://bmum2mjox5dwl diff --git a/scenes/kq4_055_seven_dwarfs_diamond_mine/kq4_055_seven_dwarfs_diamond_mine.tscn b/scenes/kq4_055_seven_dwarfs_diamond_mine/kq4_055_seven_dwarfs_diamond_mine.tscn index 6112366..d0db8bd 100644 --- a/scenes/kq4_055_seven_dwarfs_diamond_mine/kq4_055_seven_dwarfs_diamond_mine.tscn +++ b/scenes/kq4_055_seven_dwarfs_diamond_mine/kq4_055_seven_dwarfs_diamond_mine.tscn @@ -52,7 +52,7 @@ position = Vector2(174, 519) position = Vector2(50, 600) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_055_seven_dwarfs_diamond_mine" -target = "uid://qkcwifq2lb9m" +target = "uid://qkcwifq2lcam" label = "028 Mine Entrance" [node name="entrance" parent="kq4_028_mine_entrance" index="0"] diff --git a/scenes/kq4_059_baby_nursery/kq4_059_baby_nursery.tscn b/scenes/kq4_059_baby_nursery/kq4_059_baby_nursery.tscn index 271577f..e13e42f 100644 --- a/scenes/kq4_059_baby_nursery/kq4_059_baby_nursery.tscn +++ b/scenes/kq4_059_baby_nursery/kq4_059_baby_nursery.tscn @@ -39,7 +39,7 @@ position = Vector2(200, 900) position = Vector2(1452, 159) polygon = PackedVector2Array(62, 224, 50, 647, 277, 662, 295, 181) appear_at_node = "kq4_059_baby_nursery" -target = "uid://368r91sorjxs0" +target = "uid://b5eo5ndws4tin" label = "062 Bedroom" [node name="entrance" parent="kq4_062_bedroom" index="0"] diff --git a/scenes/kq4_059_baby_nursery/kq4_059_baby_nursery.tscn.uid b/scenes/kq4_059_baby_nursery/kq4_059_baby_nursery.tscn.uid index 2d0ae57..853177c 100644 --- a/scenes/kq4_059_baby_nursery/kq4_059_baby_nursery.tscn.uid +++ b/scenes/kq4_059_baby_nursery/kq4_059_baby_nursery.tscn.uid @@ -1 +1 @@ -uid://15wiem5l9oi69 +uid://dyhaubm3vhano diff --git a/scenes/kq4_060_bedroom/kq4_060_bedroom.tscn b/scenes/kq4_060_bedroom/kq4_060_bedroom.tscn index 028abae..e3b9d74 100644 --- a/scenes/kq4_060_bedroom/kq4_060_bedroom.tscn +++ b/scenes/kq4_060_bedroom/kq4_060_bedroom.tscn @@ -39,7 +39,7 @@ position = Vector2(200, 900) position = Vector2(967, 527) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_060_bedroom" -target = "uid://3hb2kqpkpvmnj" +target = "uid://bfgygdasrhic6" label = "068 The Foyer" [node name="entrance" parent="kq4_068_the_foyer" index="0"] diff --git a/scenes/kq4_060_bedroom/kq4_060_bedroom.tscn.uid b/scenes/kq4_060_bedroom/kq4_060_bedroom.tscn.uid index cd5ff41..20b8a65 100644 --- a/scenes/kq4_060_bedroom/kq4_060_bedroom.tscn.uid +++ b/scenes/kq4_060_bedroom/kq4_060_bedroom.tscn.uid @@ -1 +1 @@ -uid://abd2plmdre4f +uid://bd2plmdre4f diff --git a/scenes/kq4_062_bedroom/kq4_062_bedroom.tscn b/scenes/kq4_062_bedroom/kq4_062_bedroom.tscn index d4a0168..2b6a131 100644 --- a/scenes/kq4_062_bedroom/kq4_062_bedroom.tscn +++ b/scenes/kq4_062_bedroom/kq4_062_bedroom.tscn @@ -40,7 +40,7 @@ position = Vector2(200, 900) position = Vector2(877, 491) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_062_bedroom" -target = "uid://3hb2kqpkpvmnj" +target = "uid://bfgygdasrhic6" label = "068 The Foyer" [node name="entrance" parent="kq4_068_the_foyer" index="0"] @@ -53,7 +53,7 @@ position = Vector2(130, 684) position = Vector2(212, -39) polygon = PackedVector2Array(46, 377, 108, 814, 214, 811, 221, 419) appear_at_node = "kq4_062_bedroom" -target = "uid://15wiem5l9oi69" +target = "uid://dyhaubm3vhano" label = "059 Baby Nursery" [node name="entrance" parent="kq4_059_baby_nursery" index="0"] diff --git a/scenes/kq4_062_bedroom/kq4_062_bedroom.tscn.uid b/scenes/kq4_062_bedroom/kq4_062_bedroom.tscn.uid index 35a3179..0127632 100644 --- a/scenes/kq4_062_bedroom/kq4_062_bedroom.tscn.uid +++ b/scenes/kq4_062_bedroom/kq4_062_bedroom.tscn.uid @@ -1 +1 @@ -uid://368r91sorjxs0 +uid://b5eo5ndws4tin diff --git a/scenes/kq4_064_old_dining_room/kq4_064_old_dining_room.tscn b/scenes/kq4_064_old_dining_room/kq4_064_old_dining_room.tscn index 74192fa..4e27799 100644 --- a/scenes/kq4_064_old_dining_room/kq4_064_old_dining_room.tscn +++ b/scenes/kq4_064_old_dining_room/kq4_064_old_dining_room.tscn @@ -41,7 +41,7 @@ position = Vector2(200, 900) position = Vector2(351, -41) polygon = PackedVector2Array(-108, 454, -102, 844, 78, 811, 108, 429) appear_at_node = "kq4_064_old_dining_room" -target = "uid://3hb2kqpkpvmnj" +target = "uid://bfgygdasrhic6" label = "068 The Foyer" [node name="entrance" parent="kq4_068_the_foyer" index="0"] @@ -54,7 +54,7 @@ position = Vector2(-97, 787) position = Vector2(1217, -17) polygon = PackedVector2Array(-49, 358, -18, 700, 179, 703, 181, 351) appear_at_node = "kq4_064_old_dining_room" -target = "uid://3am8ohkla4wr9" +target = "uid://7r5j24tcpshw" label = "065 Old Kitchen" [node name="entrance" parent="kq4_065_old_kitchen" index="0"] diff --git a/scenes/kq4_064_old_dining_room/kq4_064_old_dining_room.tscn.uid b/scenes/kq4_064_old_dining_room/kq4_064_old_dining_room.tscn.uid index 43b9e3f..db23575 100644 --- a/scenes/kq4_064_old_dining_room/kq4_064_old_dining_room.tscn.uid +++ b/scenes/kq4_064_old_dining_room/kq4_064_old_dining_room.tscn.uid @@ -1 +1 @@ -uid://2y0sti59qypsl +uid://w5po4qisklh8 diff --git a/scenes/kq4_065_old_kitchen/kq4_065_old_kitchen.tscn b/scenes/kq4_065_old_kitchen/kq4_065_old_kitchen.tscn index 5d1808a..2296d81 100644 --- a/scenes/kq4_065_old_kitchen/kq4_065_old_kitchen.tscn +++ b/scenes/kq4_065_old_kitchen/kq4_065_old_kitchen.tscn @@ -39,7 +39,7 @@ position = Vector2(200, 900) position = Vector2(1103, 522) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_065_old_kitchen" -target = "uid://2y0sti59qypsl" +target = "uid://w5po4qisklh8" label = "064 Old Dining Room" [node name="entrance" parent="kq4_064_old_dining_room" index="0"] diff --git a/scenes/kq4_065_old_kitchen/kq4_065_old_kitchen.tscn.uid b/scenes/kq4_065_old_kitchen/kq4_065_old_kitchen.tscn.uid index c83d04d..c1b189d 100644 --- a/scenes/kq4_065_old_kitchen/kq4_065_old_kitchen.tscn.uid +++ b/scenes/kq4_065_old_kitchen/kq4_065_old_kitchen.tscn.uid @@ -1 +1 @@ -uid://3am8ohkla4wr9 +uid://7r5j24tcpshw diff --git a/scenes/kq4_067_the_parlor/kq4_067_the_parlor.tscn b/scenes/kq4_067_the_parlor/kq4_067_the_parlor.tscn index c68a1cb..f969e28 100644 --- a/scenes/kq4_067_the_parlor/kq4_067_the_parlor.tscn +++ b/scenes/kq4_067_the_parlor/kq4_067_the_parlor.tscn @@ -39,7 +39,7 @@ position = Vector2(200, 900) position = Vector2(50, 600) polygon = PackedVector2Array(-108, 454, -87, 649, 376, 658, 348, 381) appear_at_node = "kq4_067_the_parlor" -target = "uid://3hb2kqpkpvmnj" +target = "uid://bfgygdasrhic6" label = "068 The Foyer" [node name="entrance" parent="kq4_068_the_foyer" index="0"] diff --git a/scenes/kq4_068_the_foyer/kq4_068_the_foyer.tscn b/scenes/kq4_068_the_foyer/kq4_068_the_foyer.tscn index eaa9ff8..1e90fae 100644 --- a/scenes/kq4_068_the_foyer/kq4_068_the_foyer.tscn +++ b/scenes/kq4_068_the_foyer/kq4_068_the_foyer.tscn @@ -68,7 +68,7 @@ position = Vector2(7, 747) position = Vector2(910, 200) polygon = PackedVector2Array(-396, -99, -479, 209, -147, 228, -161, -61) appear_at_node = "kq4_068_the_foyer" -target = "uid://abd2plmdre4f" +target = "uid://bd2plmdre4f" label = "060 Bedroom" [node name="entrance" parent="kq4_060_bedroom" index="0"] @@ -81,7 +81,7 @@ position = Vector2(-311, 176) position = Vector2(973, -346) polygon = PackedVector2Array(58, 451, 108, 830, 393, 832, 348, 381) appear_at_node = "kq4_068_the_foyer" -target = "uid://368r91sorjxs0" +target = "uid://b5eo5ndws4tin" label = "062 Bedroom" metadata/_edit_lock_ = true @@ -95,7 +95,7 @@ position = Vector2(207, 737) position = Vector2(50, 600) polygon = PackedVector2Array(1448, -51, 1404, 401, 1680, 390, 1668, 85) appear_at_node = "kq4_068_the_foyer" -target = "uid://2y0sti59qypsl" +target = "uid://w5po4qisklh8" label = "064 Old Dining Room" [node name="entrance" parent="kq4_064_old_dining_room" index="0"] diff --git a/scenes/kq4_068_the_foyer/kq4_068_the_foyer.tscn.uid b/scenes/kq4_068_the_foyer/kq4_068_the_foyer.tscn.uid index 06f779c..38bc8d1 100644 --- a/scenes/kq4_068_the_foyer/kq4_068_the_foyer.tscn.uid +++ b/scenes/kq4_068_the_foyer/kq4_068_the_foyer.tscn.uid @@ -1 +1 @@ -uid://3hb2kqpkpvmnj +uid://bfgygdasrhic6 From 971e830c6a1fc88be030040fa53b53247b0f646f Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 15:55:06 -0700 Subject: [PATCH 06/10] Add Godot MCP server config to opencode.json Register the local Godot MCP server and fix playwright indentation. --- opencode.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/opencode.json b/opencode.json index 9eed6fb..b394cbc 100644 --- a/opencode.json +++ b/opencode.json @@ -6,9 +6,17 @@ "command": [ "npx", "@playwright/mcp@latest", - "--executable-path", - "/snap/bin/chromium", - "--isolated" + "--executable-path", + "/snap/bin/chromium", + "--isolated" + ], + "enabled": true + }, + "godot": { + "type": "local", + "command": [ + "node", + "/home/noti/.superset/worktrees/ai-game-2/stealth-cymbal/godot-mcp/build/index.js" ], "enabled": true } From b2c243a819eae19213179a19c842411819287a66 Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 15:55:15 -0700 Subject: [PATCH 07/10] Remove stale root-level MCP server files and navigation test The MCP interaction server lives in scripts/mcp_interaction_server.gd; the root-level copies and test file are obsolete. --- mcp_interaction_server.gd | 4421 --------------------------------- mcp_interaction_server.gd.uid | 1 - tests/test_room_navigation.py | 516 ---- 3 files changed, 4938 deletions(-) delete mode 100644 mcp_interaction_server.gd delete mode 100644 mcp_interaction_server.gd.uid delete mode 100644 tests/test_room_navigation.py diff --git a/mcp_interaction_server.gd b/mcp_interaction_server.gd deleted file mode 100644 index 6ea8256..0000000 --- a/mcp_interaction_server.gd +++ /dev/null @@ -1,4421 +0,0 @@ -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 deleted file mode 100644 index 5cf2424..0000000 --- a/mcp_interaction_server.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bxbjxf7qb74ki diff --git a/tests/test_room_navigation.py b/tests/test_room_navigation.py deleted file mode 100644 index bc24c89..0000000 --- a/tests/test_room_navigation.py +++ /dev/null @@ -1,516 +0,0 @@ -#!/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) From 8929c4fc0940b9c17cc83bde7455ee768914d4d4 Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 15:55:21 -0700 Subject: [PATCH 08/10] Add UID repair tool Python script to fix stale scene UIDs by matching transition targets to the current .uid files. --- tools/repair_uids.py | 449 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 449 insertions(+) create mode 100644 tools/repair_uids.py diff --git a/tools/repair_uids.py b/tools/repair_uids.py new file mode 100644 index 0000000..636b854 --- /dev/null +++ b/tools/repair_uids.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +"""Scans and repairs UID mismatches across all KQ4 room scenes. + +Fixes two types of drift: + 1. .tscn.headers have a different UID than the .uid companion file + 2. Source rooms reference stale target UIDs from .uid companions instead of + the current .tscn header UID + +After repair, check_transitions.py and build_room_graph.py report consistent results. + +Usage: + # Detect (show what's wrong) + python tools/repair_uids.py --detect + + # Dry run (show what would change) + python tools/repair_uids.py --fix --dry-run + + # Apply fixes + python tools/repair_uids.py --fix + + # Fix a specific room only + python tools/repair_uids.py --fix --room kq4_018_cemetery + + # Verbose output + python tools/repair_uids.py --detect -v +""" + +import re +import sys +import hashlib +import json +from pathlib import Path +from dataclasses import dataclass, field +from typing import Optional + + +def generate_godot_uid() -> str: + """Generate a fresh Godot-compatible UID (base36 encoded 64-bit random).""" + ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz" + + def to_base36(num: int) -> str: + if num == 0: + return "0" + chars = [] + while num: + num, rem = divmod(num, 36) + chars.append(ALPHABET[rem]) + return "".join(reversed(chars)) + + import secrets + value = secrets.randbits(64) + return f"uid://{to_base36(value)}" + + +@dataclass +class RoomData: + """Parsed data for a single room scene.""" + name: str + path: Path + header_uid: Optional[str] # uid from .tscn [gd_scene ...] line + companion_uid: Optional[str] # uid from .uid file (may be stale or missing) + target_uids: list[dict] # [{"exit_node": str, "target_uid": str}, ...] + + +@dataclass +class Mismatch: + """A single detected UID problem.""" + category: str # "companion_mismatch" | "missing_companion" | "stale_target" + room_name: str + detail: str + fixable: bool = True + fix_uid: Optional[str] = None + source_file: Optional[Path] = None + + +def parse_all_rooms(scenes_dir: Path) -> dict[str, RoomData]: + """Parse every kq4_* .tscn scene and return data keyed by room name.""" + rooms: dict[str, RoomData] = {} + + for tscn in sorted(scenes_dir.glob("kq4_*/kq4_*.tscn")): + if "placeholder" in str(tscn): + continue + + content = tscn.read_text() + + # Extract header UID + header_match = re.search(r'\[gd_scene[^\]]*uid="([^"]+)"', content) + header_uid = header_match.group(1) if header_match else None + + # Extract companion .uid file + uid_file = Path(str(tscn) + ".uid") + companion_uid = uid_file.read_text().strip() if uid_file.exists() else None + + # Extract target UIDs from TransitionPiece nodes + target_uids = [] + transition_pattern = re.compile( + r'\[node name="([^"]+)"[^\]]*instance=ExtResource\([^\)]+\)\]\s*\n' + r"(([^\[]+(?:\n|$))*)(?=\[|$)", + re.MULTILINE, + ) + for match in transition_pattern.finditer(content): + node_name = match.group(1) + body = match.group(2) + target_match = re.search(r'^target = "([^"]+)"', body, re.MULTILINE) + if target_match: + target_uids.append({ + "exit_node": node_name, + "target_uid": target_match.group(1), + }) + + rooms[tscn.stem] = RoomData( + name=tscn.stem, path=tscn, + header_uid=header_uid, companion_uid=companion_uid, target_uids=target_uids, + ) + + return rooms + + +def detect_mismatches(rooms: dict[str, RoomData]) -> list[Mismatch]: + """Find all UID problems and return actionable mismatch records.""" + mismatches: list[Mismatch] = [] + + # Build lookup: companion_uid -> room_name (for stale-target detection) + companion_to_room: dict[str, str] = {} + for rdata in rooms.values(): + if rdata.companion_uid: + companion_to_room[rdata.companion_uid] = rdata.name + + for rname, rd in rooms.items(): + # 1. Companion doesn't match header (both exist but differ) + if rd.header_uid and rd.companion_uid and rd.header_uid != rd.companion_uid: + mismatches.append(Mismatch( + category="companion_mismatch", + room_name=rname, + detail=f".uid={rd.companion_uid} .tscn header={rd.header_uid}", + fix_uid=rd.header_uid, source_file=Path(str(rd.path) + ".uid"), + )) + + # 2. Missing companion file + if rd.header_uid and not rd.companion_uid: + mismatches.append(Mismatch( + category="missing_companion", + room_name=rname, + detail=f"No .uid file for header UID {rd.header_uid}", + fix_uid=rd.header_uid, source_file=Path(str(rd.path) + ".uid"), + )) + + # 3. Stale target references + for tgt in rd.target_uids: + uid = tgt["target_uid"] + if not uid.startswith("uid://"): + continue + # Target exists in some header -> check companion matches + target_room = None + for other_name, other_rd in rooms.items(): + if other_rd.header_uid == uid: + target_room = other_name + break + + if target_room is None: + # Check if the stale UID matches a companion that diverged from header + mapped_companion = companion_to_room.get(uid) + if mapped_companion and mapped_companion != rooms[mapped_companion].name or \ + (mapped_companion and rooms[mapped_companion].companion_uid == uid): + # Stale companion lookup — the target room exists but source + # references its OLD companion UID instead of current header + correct_uid = rooms[mapped_companion].header_uid + if uid != correct_uid: + mismatches.append(Mismatch( + category="stale_target", + room_name=rname, + detail=f"{tgt['exit_node']} -> {uid} (correct: {correct_uid} for {mapped_companion})", + fix_uid=correct_uid, source_file=rd.path, + )) + elif target_room is None and uid not in companion_to_room: + # Totally orphaned UID — try to auto-resolve via exit node name + suggested = suggest_target_from_node(tgt["exit_node"], rooms) + if suggested: + sugg_uid = rooms[suggested].header_uid + mismatches.append(Mismatch( + category="stale_target", + room_name=rname, + detail=f"{tgt['exit_node']} -> {uid} (not found, likely → {suggested}: {sugg_uid})", + fix_uid=sugg_uid, source_file=rd.path, + )) + + return mismatches + + +def suggest_target_from_node(exit_node_name: str, rooms: dict[str, RoomData]) -> Optional[str]: + """Try to guess target room from exit node naming convention. + + Exit nodes are typically named after their destination (e.g., 'kq4_018_cemetery'). + """ + # Direct match + if exit_node_name in rooms: + return exit_node_name + + # kq4_XXX_room_node -> kq4_XXX_room (strip trailing node-like suffix) + for rname in rooms: + if exit_node_name.startswith(rname): + return rname + if rname in exit_node_name: + return rname + + return None + + +def fix_companion_mismatch(room: RoomData, new_uid: str, dry_run: bool) -> str: + """Write the header UID into the .uid companion file.""" + uid_path = Path(str(room.path) + ".uid") + content = f"{new_uid}\n" + + if not dry_run: + uid_path.write_text(content) + return f"Synchronizing {room.name}: .uid → {new_uid}" + + +def fix_missing_companion(room: RoomData, uid: str, dry_run: bool) -> str: + """Create a new .uid companion file with the header UID.""" + uid_path = Path(str(room.path) + ".uid") + content = f"{uid}\n" + + if not dry_run: + uid_path.write_text(content) + return f"Creating {room.name}: .uid ← {uid} (from .tscn header)" + + +def fix_stale_target(source_room: RoomData, exit_node: str, old_uid: str, new_uid: str, dry_run: bool) -> str: + """Replace a stale target UID in the source .tscn file.""" + content = source_room.path.read_text() + if not dry_run: + content = content.replace( + f'target = "{old_uid}"', + f'target = "{new_uid}"', + ) + source_room.path.write_text(content) + return f"Updating {source_room.name}/{exit_node}: target {old_uid} → {new_uid}" + + +def run_detect(rooms: dict[str, RoomData], verbose: bool) -> list[Mismatch]: + """Detect and display all mismatches. Returns list for further processing.""" + mismatches = detect_mismatches(rooms) + + if not mismatches: + print("No UID mismatches found. All rooms are consistent.") + return mismatches + + # Group by category + by_category: dict[str, list[Mismatch]] = {} + for m in mismatches: + by_category.setdefault(m.category, []).append(m) + + total = len(mismatches) + print(f"Found {total} UID issues across {len(rooms)} rooms:\n") + + if "companion_mismatch" in by_category: + msgs = by_category["companion_mismatch"] + print(f" [{len(msgs)}] Companion files out of sync with .tscn headers:") + for m in msgs[:10]: + print(f" {m.room_name}: {m.detail}") + if len(msgs) > 10: + print(f" ... and {len(msgs) - 10} more") + + if "missing_companion" in by_category: + msgs = by_category["missing_companion"] + print(f" [{len(msgs)}] Missing .uid companion files:") + for m in msgs: + print(f" {m.room_name}: {m.detail}") + + if "stale_target" in by_category: + msgs = by_category["stale_target"] + print(f" [{len(msgs)}] Stale target UIDs in source rooms:") + for m in (msgs[:15] if verbose else msgs[:5]): + print(f" {m.room_name}: {m.detail}") + if len(msgs) > 5 and not verbose: + print(f" ... and {len(msgs) - 5} more (use -v for full list)") + + return mismatches + + +def run_fix(rooms: dict[str, RoomData], dry_run: bool, room_filter: Optional[str]) -> int: + """Detect and fix all mismatches. Returns count of fixes applied.""" + mismatches = detect_mismatches(rooms) + + if not mismatches: + print("No UID mismatches to fix.") + return 0 + + if room_filter: + mismatches = [m for m in mismatches if m.room_name == room_filter] + if not mismatches: + print(f"No issues found for {room_filter}.") + return 0 + + mode = "(DRY RUN) " if dry_run else "" + applied = 0 + + for m in mismatches: + if not m.fixable or not m.fix_uid: + continue + + rd = rooms.get(m.room_name) + if not rd: + continue + + if m.category == "companion_mismatch" and m.source_file: + msg = fix_companion_mismatch(rd, m.fix_uid, dry_run) + print(f"{mode}✓ {msg}") + applied += 1 + + elif m.category == "missing_companion": + # Check if room needs a new UID generated + uid_to_use = m.fix_uid or rd.header_uid + if not uid_to_use: + uid_to_use = generate_godot_uid() + if not dry_run: + # Also update the .tscn header with the new UID + content = rd.path.read_text() + if rd.header_uid: + content = content.replace(f'uid="{rd.header_uid}"', f'uid="{uid_to_use}"') + else: + content = content.replace('[gd_scene format=3', f'[gd_scene format=3 uid="{uid_to_use}"') + rd.path.write_text(content) + msg = fix_missing_companion(rd, uid_to_use, dry_run) + print(f"{mode}✓ {msg}") + applied += 1 + + elif m.category == "stale_target": + # Extract old UID from detail message + detail_parts = m.detail.split(" -> ") + if len(detail_parts) >= 2: + exit_node = detail_parts[0].split()[-1] + old_uid = detail_parts[1] + new_uid = m.fix_uid + msg = fix_stale_target(rd, exit_node, old_uid or "", new_uid, dry_run) + + # Handle case where target UID doesn't exist in any companion file + if "not found" in m.detail.lower(): + # Extract via regex for more robust parsing + import re + uid_match = re.search(r"-> (uid://\S+)", m.detail) + if uid_match: + old_uid = uid_match.group(1) + else: + continue + + exit_node_match = re.search(r"(kq4_\S+) -> ", m.detail) + exit_node = exit_node_match.group(1) if exit_node_match else "unknown" + + msg = fix_stale_target(rd, exit_node, old_uid, new_uid, dry_run) + + print(f"{mode}✓ {msg}") + applied += 1 + + return applied + + +def run_verify() -> bool: + """Run check_transitions.py and build_room_graph.py to confirm fixes.""" + root = Path(__file__).resolve().parent.parent + check_script = root / "scripts" / "check_transitions.py" + graph_script = root / "scripts" / "build_room_graph.py" + + print("\n--- Post-fix verification ---\n") + all_clean = True + + if check_script.exists(): + import subprocess + print("$ python scripts/check_transitions.py") + result = subprocess.run(["python", str(check_script)], cwd=str(root), capture_output=True, text=True) + lines = [l for l in result.stdout.strip().split("\n") if "ERROR:" in l] + if lines: + all_clean = False + print(f" Still has {len(lines)} error(s):") + for l in lines[:10]: + print(f" {l.strip()}") + if len(lines) > 10: + print(f" ... and {len(lines) - 10} more") + else: + OK_count = sum(1 for l in result.stdout.split("\n") if "OK:" in l) + print(f" All {OK_count} transitions valid! ✓\n") + else: + print(f"[skip] check_transitions.py not found at {check_script}\n") + + if graph_script.exists(): + import subprocess + print("$ python scripts/build_room_graph.py") + result = subprocess.run(["python", str(graph_script)], cwd=str(root), capture_output=True, text=True) + output = result.stdout.strip().split("\n") + for line in output[:8]: + print(f" {line}") + + # Check if all rooms are connected or note component issues + for l in output: + if "component" in l.lower(): + if "all rooms connected" not in l.lower(): + comp_count = re.search(r"(\d+)", l) + if comp_count and int(comp_count.group(1)) > 1: + print(f" Note: {l.strip()} (some rooms disconnected by missing transitions)") + else: + print(f"[skip] build_room_graph.py not found at {graph_script}\n") + + return all_clean + + +def main() -> None: + import argparse + parser = argparse.ArgumentParser( + description="Detect and repair UID mismatches in KQ4 room scenes", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--detect", action="store_true", help="Only detect mismatches") + parser.add_argument("--fix", action="store_true", help="Apply fixes for found mismatches") + parser.add_argument("--dry-run", action="store_true", help="Show what would change without writing") + parser.add_argument("--room", help="Limit to a specific room name (e.g., kq4_018_cemetery)") + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + + 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) + + rooms = parse_all_rooms(scenes_dir) + + if args.detect and not args.fix: + mismatches = run_detect(rooms, args.verbose) + if mismatches: + sys.exit(0) + + if args.fix: + applied = run_fix(rooms, args.dry_run, args.room) + print(f"\nApplied {applied} fix(es)" + (" (dry run — no changes made)" if args.dry_run else "")) + if not args.dry_run and applied > 0: + try: + clean = run_verify() + if not clean: + sys.exit(1) + except Exception as e: + print(f"[verification warning] {e}") + + +if __name__ == "__main__": + main() From c24709d02d8e8ce441946c5db594b4bd6a498ffb Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 16:18:02 -0700 Subject: [PATCH 09/10] docs: update room navigator skill with correct get_current_room_name method Replace raw eval JSON with godot_game_call_method for checking current room, matching the actual Godot MCP tool interface used in practice. --- .opencode/skills/kq4-room-navigator/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.opencode/skills/kq4-room-navigator/SKILL.md b/.opencode/skills/kq4-room-navigator/SKILL.md index 405e546..15646a7 100644 --- a/.opencode/skills/kq4-room-navigator/SKILL.md +++ b/.opencode/skills/kq4-room-navigator/SKILL.md @@ -35,8 +35,9 @@ Room identification at runtime: The canonical way is via `MainGame.get_current_room_name()`: +Use `godot_game_call_method` to call it directly on the node path: ```json -{"command": "eval", "params": {"code": "get_tree().root.get_node('Node2D').get_current_room_name()"}} +Call method get_current_room_name on /root/Node2D ``` Returns a string like `"kq4_010_forest_path"`. The implementation extracts the filename from the scene script's resource path (`script_path.trim_suffix(".gd").get_file()`), NOT the node name (which is always `"background"`). From 0b560b94ca3d96a374b7685caa02752f55b46896 Mon Sep 17 00:00:00 2001 From: Bryce Date: Wed, 29 Apr 2026 16:54:01 -0700 Subject: [PATCH 10/10] docs: add code review for PR 3 Comprehensive code review findings: - 23 total findings (2 P1, 9 P2, 12 P3) - Key issues: UID mismatch in kq4_004_ogres_cottage, missing tests - Verdict: Ready with fixes --- docs/code-review-pr3.md | 135 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/code-review-pr3.md diff --git a/docs/code-review-pr3.md b/docs/code-review-pr3.md new file mode 100644 index 0000000..6ed9326 --- /dev/null +++ b/docs/code-review-pr3.md @@ -0,0 +1,135 @@ +## Code Review: PR 3 - "Stealth Cymbal" + +### Scope +- **Branch:** stealth-cymbal +- **Base:** master +- **Files Changed:** 86 files (~1576 insertions, ~230 deletions) +- **Key Changes:** KQ4 Room Navigator skill, UID repair tools, MCP interaction server, room scene updates + +### Review Team +- ✅ **ce-correctness-reviewer** - Logic errors, edge cases, state bugs +- ✅ **ce-testing-reviewer** - Coverage gaps, weak assertions, brittle tests +- ✅ **ce-maintainability-reviewer** - Coupling, complexity, naming, dead code +- ✅ **ce-project-standards-reviewer** - CLAUDE.md and AGENTS.md compliance +- ✅ **ce-agent-native-reviewer** - Agent-accessible design patterns +- ✅ **ce-learnings-researcher** - Past issues in docs/solutions/ + +--- + +### P1 -- High + +| # | File | Issue | Reviewer | Confidence | Route | +|---|------|------|----------|------------|-------| +| 1 | `scenes/kq4_004_ogres_cottage/kq4_004_ogres_cottage.tscn:46` | UID mismatch - target UID for kq4_028_mine_entrance points to non-existent room | correctness | 75% | manual → downstream-resolver | +| 2 | `scripts/mcp_interaction_server.gd` | No runtime tests for 80+ TCP command handlers (171KB script) | testing | 50% | manual → downstream-resolver | + +--- + +### P2 -- Moderate + +| # | File | Issue | Reviewer | Confidence | Route | +|---|------|------|----------|------------|-------| +| 3 | `scenes/kq4_098_transitional_room/kq4_098_transitional_room.tscn` | Missing .uid companion file | correctness | 75% | manual → review-fixer | +| 4 | `scripts/build_room_graph.py:184` | `_resolve_target_room` returns None without explicit handling in callers | correctness | 75% | safe_auto → review-fixer | +| 5 | `tools/repair_uids.py:322` | Code duplication in fix_stale_target with inconsistent error handling | correctness | 75% | safe_auto → review-fixer | +| 6 | `tools/repair_uids.py` | No tests for file mutation operations (UID sync, target replacement) | testing | 75% | manual → downstream-resolver | +| 7 | `scripts/build_room_graph.py` | No tests for BFS edge cases (disconnected components, malformed UIDs) | testing | 75% | manual → downstream-resolver | +| 8 | `MainGame.gd`, `SetPiece_.gd` | No tests for signal emission logic and cursor action routing | testing | 50% | manual → downstream-resolver | +| 9 | `SetPiece_.gd:mock_interact()` | Unnecessary indirection - duplicates `_input()` logic | maintainability | 75% | gated_auto → downstream-resolver | +| 10 | `SetPiece_.gd` | Unused `entered/exited` signals with unclear `lab` param | maintainability | 75% | gated_auto → downstream-resolver | +| 11 | `tools/kq4_room_navigator.py` | sys.path manipulation creates coupling | maintainability | 50% | gated_auto → downstream-resolver | + +--- + +### P3 -- Low + +| # | File | Issue | Reviewer | Confidence | Route | +|---|------|------|----------|------------|-------| +| 12 | `MainGame.gd:13` | `get_current_room_name()` returns empty string on failure - silent failure mode | correctness | 50% | advisory → downstream-resolver | +| 13 | `scripts/mcp_interaction_server.gd:42` | Fixed 30-second timeout could mask real hangs | correctness | 50% | advisory → downstream-resolver | +| 14 | `SetPiece_.gd:60` | `mock_interact()` doesn't validate target room exists | correctness | 50% | advisory → downstream-resolver | +| 15 | `tools/kq4_room_navigator.py` | No tests for CLI tool | testing | 75% | advisory → human | +| 16 | `MainGame.gd` | Placeholder comment in `_ready()` - dead code | maintainability | 100% | advisory → human | +| 17 | `MainGame.gd` | Commented-out code with typo - dead code | maintainability | 100% | advisory → human | +| 18 | `scripts/mcp_interaction_server.gd` | Potentially incomplete implementation | maintainability | 50% | advisory → human | +| 19 | `.opencode/skills/kq4-room-navigator/SKILL.md:38-41` | Code block formatting changed from JSON to plain text | project-standards | 75% | advisory → human | +| 20 | `tools/kq4_room_navigator.py` | Dataclass overhead for Mismatch in CLI tool | maintainability | 50% | advisory → human | +| 21 | `.opencode/skills/kq4-room-navigator/SKILL.md` | Missing explicit retry guidance for `_busy` protocol | agent-native | 75% | advisory → human | +| 22 | `.opencode/skills/kq4-room-navigator/SKILL.md` | `mock_interact` vs `mock_interaction` typo in error messages | agent-native | 75% | safe_auto → review-fixer | +| 23 | `.opencode/skills/kq4-room-navigator/SKILL.md` | `--navigate` flag documented but not implemented in CLI | agent-native | 50% | gated_auto → downstream-resolver | + +--- + +### Requirements Completeness +**No plan document found** for this PR. The PR title "Stealth Cymbal" is ambiguous and doesn't reference a specific plan. + +--- + +### Pre-existing Issues +None identified in this review. + +--- + +### Learnings & Past Solutions +**No institutional learnings found.** The `docs/solutions/` directory does not exist in this repository. Consider using `/ce-compound` skill after significant work to document: +- UID generation patterns (make_uid.py + .uid files) +- Navigation/pathfinding setup (NavigationServer2D) +- MCP server integration learnings +- Room transition wiring patterns + +--- + +### Agent-Native Gaps +1. **Offline-Only Graph Discovery** - Agents can't dynamically discover room connectivity at runtime +2. **Manual Verification** - Navigation completion requires polling instead of signals +3. **No Path Validation** - Agents can attempt invalid navigation without guardrails +4. **Incomplete CLI** - The `--navigate` flag is documented but not implemented + +--- + +### Residual Risks +1. **UID mismatch risk** - Room graph connectivity depends on UID consistency across all .tscn and .uid files. If UIDs drift from manual edits, pathfinding will fail even though runtime transitions work. + - **Mitigation:** Run `tools/repair_uids.py --fix` before each significant room update. Consider integrating UID validation into CI pipeline. + +2. **Timeout risk** - The MCP server's `_busy` flag uses a fixed 30-second timeout. If navigation animations or other operations take longer, commands may be prematurely reset. + - **Mitigation:** Monitor `_busy` timeout warnings in logs. Consider making configurable or implementing per-command timeout overrides. + +3. **Arbitrary code execution** - MCP eval command allows arbitrary code execution without guardrails. + +4. **Heuristic risk** - UID auto-resolution in repair_uids.py may produce incorrect fixes based on heuristics. + +--- + +### Testing Gaps +1. **No automated tests for UID consistency** across room transitions +2. **No tests for pathfinding edge cases** (disconnected rooms, single-room graphs, self-loops) +3. **No tests for MCP server command timeout** behavior under load +4. **No validation** that all TransitionPiece targets point to existing rooms +5. **No unit tests** for UID mismatch detection in repair_uids.py +6. **No tests** for BFS pathfinding edge cases in build_room_graph.py +7. **No tests** for SetPiece signal emission logic +8. **No tests** for busy-state timeout recovery in MCP server + +--- + +### Coverage +- **Findings:** 23 total (2 P1, 9 P2, 12 P3) +- **Safe-auto (fixable now):** 3 findings +- **Manual (needs handoff):** 4 findings +- **Gated-auto (needs review):** 4 findings +- **Advisory (report-only):** 12 findings +- **Failed reviewers:** 0 of 6 + +--- + +### Verdict +**Ready with fixes** + +**Fix order:** +1. **Critical:** Fix UID mismatch in kq4_004_ogres_cottage.tscn (finding #1) - this breaks navigation +2. **High:** Add missing .uid file for kq4_098_transitional_room (finding #3) +3. **Medium:** Fix None handling in build_room_graph.py (finding #4) +4. **Medium:** Consolidate code duplication in repair_uids.py (finding #5) +5. **Low:** Address dead code and formatting issues (findings #12-23) + +**Before merge:** Run `tools/repair_uids.py --fix` to synchronize all UIDs and verify the UID mismatch is resolved.