@tool extends RefCounted const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd") ## Handles Camera2D / Camera3D authoring — create, configure, bounds, damping, ## node-parent-based follow, presets. ## ## All writes are bundled into a single EditorUndoRedoManager action. ## Setting current=true auto-unmarks previously-current cameras of the same ## class in the same action so one Ctrl-Z reverts the switch. const CameraValues := preload("res://addons/godot_ai/handlers/camera_values.gd") const CameraPresets := preload("res://addons/godot_ai/handlers/camera_presets.gd") const _VALID_TYPES := { "2d": "Camera2D", "3d": "Camera3D", } const _KEYS_2D := [ "zoom", "offset", "anchor_mode", "ignore_rotation", "enabled", "current", "process_callback", "position_smoothing_enabled", "position_smoothing_speed", "rotation_smoothing_enabled", "rotation_smoothing_speed", "drag_horizontal_enabled", "drag_vertical_enabled", "drag_horizontal_offset", "drag_vertical_offset", "drag_left_margin", "drag_top_margin", "drag_right_margin", "drag_bottom_margin", "limit_left", "limit_right", "limit_top", "limit_bottom", "limit_smoothed", ] const _KEYS_3D := [ "fov", "near", "far", "size", "projection", "keep_aspect", "cull_mask", "doppler_tracking", "h_offset", "v_offset", "current", ] # Transform-shaped keys live on Node2D / Node3D, not in the camera-specific # schema — rejecting them without a hint sends agents searching for the wrong # tool. const _NODE_TRANSFORM_KEYS := [ "position", "rotation", "scale", "transform", "global_position", "global_rotation", "global_scale", "global_transform", ] const _DAMPING_MARGIN_KEYS := ["left", "top", "right", "bottom"] const _CURRENT_SETTLE_ATTEMPTS := 8 const _CURRENT_SETTLE_DELAY_MSEC := 10 var _undo_redo: EditorUndoRedoManager # Per-scene logical-current bookkeeping. Keys are scene-root InstanceIDs; # values are { "2d": NodePath-as-String, "3d": NodePath-as-String } with # missing keys meaning "no logical current for that class." # # Stored on the handler instance (NOT as Node metadata on the scene root) # because set_meta() persists into the .tscn on save, contaminating user # scene files with MCP-internal sidecar state that lingers across reloads # and travels in commits. var _logical_current: Dictionary = {} func _init(undo_redo: EditorUndoRedoManager) -> void: _undo_redo = undo_redo # Camera2D doesn't expose `current` as a settable property in Godot 4 — # only is_current() / make_current() / clear_current(). Camera3D exposes # both, but using methods uniformly avoids per-class branching. static func _is_current(cam: Node) -> bool: if cam == null: return false return bool(cam.is_current()) static func _viewport_current_camera(scene_root: Node) -> Node: if scene_root == null: return null var viewport := scene_root.get_viewport() if viewport == null: return null var current_2d := viewport.get_camera_2d() if current_2d != null and scene_root.is_ancestor_of(current_2d): return current_2d var current_3d := viewport.get_camera_3d() if current_3d != null and scene_root.is_ancestor_of(current_3d): return current_3d return null static func _is_effective_current(cam: Node) -> bool: if _is_current(cam): return true if cam is Camera2D: var viewport_2d := cam.get_viewport() return viewport_2d != null and viewport_2d.get_camera_2d() == cam if cam is Camera3D: var viewport_3d := cam.get_viewport() return viewport_3d != null and viewport_3d.get_camera_3d() == cam return false # Logical-current bookkeeping. Updated from inside _apply_make_current / # _apply_clear_current so DO and UNDO callables stamp the same logical # slot they touch in the viewport. Reads consult the logical slot first # and treat it as authoritative when set — the viewport read is the # fallback for "MCP never touched this scene's cameras." func _set_logical_current(cam: Node) -> void: if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): return var type_str := _camera_type_str(cam) if type_str.is_empty(): return var scene_root := EditorInterface.get_edited_scene_root() if scene_root == null or not scene_root.is_ancestor_of(cam): return var slot: Dictionary = _logical_current.get(scene_root.get_instance_id(), {}) slot[type_str] = McpScenePath.from_node(cam, scene_root) _logical_current[scene_root.get_instance_id()] = slot func _clear_logical_current(cam: Node) -> void: if cam == null: return var type_str := _camera_type_str(cam) if type_str.is_empty(): return var scene_root := EditorInterface.get_edited_scene_root() if scene_root == null: return var key := scene_root.get_instance_id() if not _logical_current.has(key): return var slot: Dictionary = _logical_current[key] if not slot.has(type_str): return # Only clear if the logical slot still points at this camera; otherwise # a later make_current already took the slot and we'd stomp it. var current_path := "" if is_instance_valid(cam) and cam.is_inside_tree() and scene_root.is_ancestor_of(cam): current_path = McpScenePath.from_node(cam, scene_root) if String(slot[type_str]) == current_path: slot.erase(type_str) if slot.is_empty(): _logical_current.erase(key) else: _logical_current[key] = slot func _logical_current_camera(scene_root: Node, type_str: String = "") -> Node: if scene_root == null: return null var key := scene_root.get_instance_id() if not _logical_current.has(key): return null var slot: Dictionary = _logical_current[key] var types: Array[String] = [] if type_str == "2d" or type_str == "3d": types = [type_str] else: types = ["2d", "3d"] for t in types: if not slot.has(t): continue var path := String(slot[t]) if path.is_empty(): slot.erase(t) continue var node := McpScenePath.resolve(path, scene_root) if node == null or not _is_camera(node) or _camera_type_str(node) != t: slot.erase(t) continue return node if slot.is_empty(): _logical_current.erase(key) else: _logical_current[key] = slot return null func _is_logical_current(scene_root: Node, cam: Node) -> bool: if scene_root == null or cam == null: return false var logical := _logical_current_camera(scene_root, _camera_type_str(cam)) return logical != null and logical == cam # Public introspection for tests that need to distinguish "handler has a # logical marker" from "handler is falling back to engine state". `get_camera` # / `list_cameras` both use `_resolve_current` which falls through to # `_is_effective_current` when no marker is set — that's correct for callers # but masks the marker presence from anyone trying to gate on # "did the handler actually record this state?". Returns the logical-current # Camera2D / Camera3D for the given type ("2d" / "3d" / "" for either), or # null when no marker is set. See #316 PR #372 review feedback. func peek_logical_current(scene_root: Node, type_str: String = "") -> Node: return _logical_current_camera(scene_root, type_str) # Authoritative answer for "is `cam` the current camera of its class?" # # When a logical marker exists for the camera's class, it is the single # source of truth — only the marker's referenced camera reports current, # every other camera of that class reports false even if the viewport # slot still points at one of them (the headless-CI lag in #140 / #278 / # #301). Without a logical marker, fall through to the viewport read so # scenes MCP never touched still answer correctly. func _resolve_current(scene_root: Node, cam: Node) -> bool: if scene_root == null or cam == null: return false var logical := _logical_current_camera(scene_root, _camera_type_str(cam)) if logical != null: return logical == cam return _is_effective_current(cam) # list_cameras pre-fetches the per-class logical pointers once; this # variant takes those pointers to avoid an O(n²) walk over the meta # bookkeeping for each camera in the scene. func _resolve_current_with_logicals(cam: Node, logical_2d: Node, logical_3d: Node) -> bool: if cam == null: return false if cam is Camera2D: if logical_2d != null: return logical_2d == cam elif cam is Camera3D: if logical_3d != null: return logical_3d == cam return _is_effective_current(cam) # Register a current=true switch on `node` in the open undo action, # unmarking previously-current siblings of the same class so a single # Ctrl-Z reverts the whole switch. # # Both DO and UNDO route through `_apply_make_current` / `_apply_clear_current` # on the handler itself rather than calling Camera.make_current() directly. # The helpers do the make_current (or clear_current) call plus bounded sync # settling when the viewport hasn't yet reflected the change — headless CI # occasionally reports `is_current() == false` immediately after a committed # make_current (observed CI run 24682342469) and symmetrically still reports # the displaced camera as current immediately after an undo (observed CI runs # 24682342469, 24692250322, 24696571517, 25079965242 — tracked in #140). # Later #278 runs broadened the same current-camera timing flake across more # platforms and assertions, so the settle budget is deliberately above one # fast local frame. # # Because those callables bind to `self` (a RefCounted handler, not a scene # node), every action that calls this helper must pin its history via # `create_action(name, MERGE_DISABLE, scene_root)` — otherwise the # handler-bound ops land in GLOBAL_HISTORY while the scene-node ops land in # the scene's history, and a single editor_undo reverts only half the action. # # Both DO and UNDO use a single make_current() call — never a # clear_current() + make_current() pair. make_current() takes over the # viewport slot atomically (Godot enforces one current camera per class # per viewport), so the displaced camera naturally returns # is_current() == false without an explicit clear. The two-step approach # leaves the viewport temporarily with no current camera between the # clear and the make, which races with editor cleanup on macOS headless # (observed flaking CI runs 24674252085, 24675424785). func _add_make_current_to_action(node: Node, type_str: String, scene_root: Node) -> void: var prev_current: Node = null for cam in _list_cameras_in_scene(scene_root, type_str): if cam == node: continue if _resolve_current(scene_root, cam): prev_current = cam break _undo_redo.add_do_method(self, "_apply_make_current", node) if prev_current != null: _undo_redo.add_undo_method(self, "_apply_make_current", prev_current) else: _undo_redo.add_undo_method(self, "_apply_clear_current", node) # Apply make_current on `cam` with bounded synchronous settling. Registered as the # do/undo callable by `_add_make_current_to_action`. See that function's # comment for why the undo path needs the retry inside the action itself. # Safe against a freed camera node — short-circuits if the node is gone # or not in the tree. func _apply_make_current(cam: Node) -> void: if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): return _set_logical_current(cam) var scene_root := EditorInterface.get_edited_scene_root() var type_str := _camera_type_str(cam) for attempt in range(_CURRENT_SETTLE_ATTEMPTS): cam.make_current() _force_camera_refresh(cam) # Godot's make_current is supposed to atomically displace siblings, # but on macOS headless the displaced camera occasionally still # answers is_current() == true after this returns (#140 / #278 / #301). # Sweep same-class siblings and clear any that lag. _force_clear_other_currents(cam, type_str, scene_root) if not _is_current_settled(cam): _displace_stale_camera_2d(cam) _force_clear_other_currents(cam, type_str, scene_root) var waited_this_attempt := false if _is_current_settled(cam): if not (cam is Camera2D): return OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) waited_this_attempt = true _force_camera_refresh(cam) _force_clear_other_currents(cam, type_str, scene_root) if _is_current_settled(cam): return if attempt < _CURRENT_SETTLE_ATTEMPTS - 1 and not waited_this_attempt: OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) # Walk same-class siblings and force-clear any that still report is_current(). # Best-effort: clear_current errors when called on a non-current camera, so # guard. Camera2D's clear_current path also flushes the viewport slot, which # is the one we actually care about settling for #301. func _force_clear_other_currents(target: Node, type_str: String, scene_root: Node) -> void: if scene_root == null or type_str.is_empty(): return for sibling in _list_cameras_in_scene(scene_root, type_str): if sibling == target: continue if not is_instance_valid(sibling) or not sibling.is_inside_tree(): continue if not _is_current(sibling): # Even if is_current() reports false, the viewport slot can # still point at this sibling on macOS — re-make target to # take it back. Cheap (idempotent) when the slot is fine. if sibling is Camera2D: var vp_other: Viewport = (sibling as Camera2D).get_viewport() if vp_other != null and vp_other.get_camera_2d() == sibling: target.make_current() _force_camera_refresh(target) continue sibling.clear_current() if sibling is Camera2D: (sibling as Camera2D).force_update_scroll() # Call after commit_action() whenever the action registered a make_current DO. # The undo path cannot use a post-undo hook, so it relies on `_apply_make_current` # directly; create/configure/apply_preset get this extra post-commit verifier. func _verify_current_after_commit(node: Node) -> void: _apply_make_current(node) func _force_camera_refresh(cam: Node) -> void: if cam is Camera2D: (cam as Camera2D).force_update_scroll() func _is_current_settled(cam: Node) -> bool: if not _is_current(cam): return false if cam is Camera2D: var viewport := cam.get_viewport() if viewport != null and viewport.get_camera_2d() != cam: return false return true func _displace_stale_camera_2d(target: Node) -> void: if not (target is Camera2D): return var viewport := target.get_viewport() if viewport == null: return var stale := viewport.get_camera_2d() if stale == null or stale == target or not is_instance_valid(stale): _nudge_camera_2d_current(target) return var was_enabled := stale.enabled if was_enabled: stale.enabled = false target.make_current() _force_camera_refresh(target) if was_enabled: stale.enabled = true target.make_current() _force_camera_refresh(target) func _nudge_camera_2d_current(target: Node) -> void: if not (target is Camera2D): return var cam := target as Camera2D if not cam.enabled: return cam.enabled = false _force_camera_refresh(cam) cam.enabled = true cam.make_current() _force_camera_refresh(cam) # Symmetric counterpart to `_apply_make_current` for the "no previous # current camera" branch (create_camera with make_current=true and no # sibling was current). clear_current errors in Godot if called on a # non-current camera, so guard on is_current first. func _apply_clear_current(cam: Node) -> void: if cam == null or not is_instance_valid(cam) or not cam.is_inside_tree(): return _clear_logical_current(cam) for attempt in range(_CURRENT_SETTLE_ATTEMPTS): if _is_clear_settled(cam): return if _is_current(cam): cam.clear_current() _force_camera_refresh(cam) # Camera2D-only: is_current() may answer false while the viewport # slot still points at cam. Toggle enabled to force the viewport # to release, then restore. if cam is Camera2D: var vp := cam.get_viewport() if vp != null and vp.get_camera_2d() == cam: var was_enabled := (cam as Camera2D).enabled if was_enabled: (cam as Camera2D).enabled = false _force_camera_refresh(cam) if was_enabled: (cam as Camera2D).enabled = true if _is_clear_settled(cam): return if attempt < _CURRENT_SETTLE_ATTEMPTS - 1: OS.delay_msec(_CURRENT_SETTLE_DELAY_MSEC) func _is_clear_settled(cam: Node) -> bool: if cam == null: return true if _is_current(cam): return false if cam is Camera2D: var vp := cam.get_viewport() if vp != null and vp.get_camera_2d() == cam: return false return true # ============================================================================ # camera_create # ============================================================================ func create_camera(params: Dictionary) -> Dictionary: var parent_path: String = params.get("parent_path", "") var node_name: String = params.get("name", "Camera") var type_str: String = params.get("type", "2d") var make_current: bool = bool(params.get("make_current", false)) if not _VALID_TYPES.has(type_str): return ErrorCodes.make( ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid camera type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] ) var _scene_check := McpNodeValidator.require_scene_or_error() if _scene_check.has("error"): return _scene_check var scene_root: Node = _scene_check.scene_root var parent: Node = scene_root if not parent_path.is_empty(): parent = McpScenePath.resolve(parent_path, scene_root) if parent == null: return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) var node := _instantiate_camera(type_str) if node == null: return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate camera") if not node_name.is_empty(): node.name = node_name _undo_redo.create_action( "MCP: Create %s '%s'" % [_VALID_TYPES[type_str], node.name], UndoRedo.MERGE_DISABLE, scene_root ) _undo_redo.add_do_method(parent, "add_child", node, true) _undo_redo.add_do_method(node, "set_owner", scene_root) _undo_redo.add_do_reference(node) if make_current: # Must land AFTER add_child: making current before the node is in the # tree is a silent no-op on the viewport. _add_make_current_to_action(node, type_str, scene_root) _undo_redo.add_undo_method(parent, "remove_child", node) _undo_redo.commit_action() if make_current: _verify_current_after_commit(node) return { "data": { "path": McpScenePath.from_node(node, scene_root), "parent_path": McpScenePath.from_node(parent, scene_root), "name": String(node.name), "type": type_str, "class": _VALID_TYPES[type_str], "current": bool(make_current), "undoable": true, } } # ============================================================================ # camera_configure # ============================================================================ func configure(params: Dictionary) -> Dictionary: var resolved := _resolve_camera(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var type_str: String = resolved.type var scene_root: Node = resolved.scene_root var properties: Dictionary = params.get("properties", {}) if properties.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "properties dict is empty") var valid_keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D var prop_types := _property_type_map(node) var coerced: Dictionary = {} var old_values: Dictionary = {} # `current` is special-cased via methods (Camera2D doesn't expose it as a property). var current_request: Variant = null for property in properties: var prop_name: String = String(property) if not (prop_name in valid_keys): var msg := "Property '%s' not valid for %s. Valid: %s" % [ prop_name, _VALID_TYPES[type_str], ", ".join(valid_keys) ] if prop_name in _NODE_TRANSFORM_KEYS: msg += ( ". Transforms live on the Node, not on the camera config — " + "use node_set_property(path=%s, property=\"%s\", value=...)" % [node_path, prop_name] ) return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, msg) if prop_name == "current": current_request = bool(properties[prop_name]) continue var prop_type: int = prop_types.get(prop_name, TYPE_NIL) if prop_type == TYPE_NIL: return ErrorCodes.make( ErrorCodes.PROPERTY_NOT_ON_CLASS, "Property '%s' not present on %s" % [prop_name, node.get_class()] ) var coerce_result := CameraValues.coerce(prop_name, properties[prop_name], prop_type) if not coerce_result.ok: return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) coerced[prop_name] = coerce_result.value old_values[prop_name] = node.get(prop_name) _undo_redo.create_action( "MCP: Configure camera %s" % node.name, UndoRedo.MERGE_DISABLE, scene_root ) for prop_name in coerced: _undo_redo.add_do_property(node, prop_name, coerced[prop_name]) _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) var verify_current_after := false if current_request != null: var want_on: bool = bool(current_request) var was_on: bool = _resolve_current(scene_root, node) if want_on and not was_on: _add_make_current_to_action(node, type_str, scene_root) verify_current_after = true elif not want_on and was_on: _undo_redo.add_do_method(self, "_apply_clear_current", node) _undo_redo.add_undo_method(self, "_apply_make_current", node) _undo_redo.commit_action() if verify_current_after: _verify_current_after_commit(node) var applied: Array[String] = [] var serialized: Dictionary = {} for prop_name in coerced: applied.append(prop_name) serialized[prop_name] = CameraValues.serialize(coerced[prop_name]) if current_request != null: applied.append("current") serialized["current"] = bool(current_request) return { "data": { "path": node_path, "type": type_str, "class": node.get_class(), "applied": applied, "values": serialized, "undoable": true, } } # ============================================================================ # camera_set_limits_2d # ============================================================================ func set_limits_2d(params: Dictionary) -> Dictionary: var resolved := _resolve_camera(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var type_str: String = resolved.type if type_str != "2d": return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "camera_set_limits_2d requires a Camera2D (got %s)" % node.get_class() ) var applied: Dictionary = {} var old_values: Dictionary = {} var edges := { "left": "limit_left", "right": "limit_right", "top": "limit_top", "bottom": "limit_bottom", } for edge in edges: var v = params.get(edge) if v != null: var prop_name: String = edges[edge] applied[prop_name] = int(v) old_values[prop_name] = node.get(prop_name) var smoothed = params.get("smoothed") if smoothed != null: applied["limit_smoothed"] = bool(smoothed) old_values["limit_smoothed"] = node.get("limit_smoothed") if applied.is_empty(): return ErrorCodes.make( ErrorCodes.MISSING_REQUIRED_PARAM, "No limits specified; provide at least one of left, right, top, bottom, smoothed" ) _undo_redo.create_action("MCP: Set camera limits on %s" % node.name) for prop_name in applied: _undo_redo.add_do_property(node, prop_name, applied[prop_name]) _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) _undo_redo.commit_action() var values: Dictionary = {} for prop_name in applied: values[prop_name] = applied[prop_name] return { "data": { "path": node_path, "applied": applied.keys(), "values": values, "undoable": true, } } # ============================================================================ # camera_set_damping_2d # ============================================================================ func set_damping_2d(params: Dictionary) -> Dictionary: var resolved := _resolve_camera(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var type_str: String = resolved.type if type_str != "2d": return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "camera_set_damping_2d requires a Camera2D (got %s)" % node.get_class() ) var applied: Dictionary = {} var old_values: Dictionary = {} # position_speed: set position_smoothing_speed AND toggle position_smoothing_enabled. var pos_v = params.get("position_speed") if pos_v != null: var pos_speed := float(pos_v) var pos_enable := pos_speed > 0.0 applied["position_smoothing_enabled"] = pos_enable old_values["position_smoothing_enabled"] = node.get("position_smoothing_enabled") if pos_enable: applied["position_smoothing_speed"] = pos_speed old_values["position_smoothing_speed"] = node.get("position_smoothing_speed") # rotation_speed: same pattern for rotation_smoothing_*. var rot_v = params.get("rotation_speed") if rot_v != null: var rot_speed := float(rot_v) var rot_enable := rot_speed > 0.0 applied["rotation_smoothing_enabled"] = rot_enable old_values["rotation_smoothing_enabled"] = node.get("rotation_smoothing_enabled") if rot_enable: applied["rotation_smoothing_speed"] = rot_speed old_values["rotation_smoothing_speed"] = node.get("rotation_smoothing_speed") for flag in ["drag_horizontal_enabled", "drag_vertical_enabled"]: var flag_v = params.get(flag) if flag_v != null: applied[flag] = bool(flag_v) old_values[flag] = node.get(flag) # drag_margins: dict {left, top, right, bottom} floats in [0,1]; null/missing keys untouched. var margins_v = params.get("drag_margins") if margins_v != null: if not (margins_v is Dictionary): return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "drag_margins must be a dict with optional keys left/top/right/bottom" ) var margins: Dictionary = margins_v for edge in _DAMPING_MARGIN_KEYS: var margin_v = margins.get(edge) if margin_v == null: continue var v := float(margin_v) if v < 0.0 or v > 1.0: return ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "drag_margins.%s must be in [0, 1] (got %s)" % [edge, v] ) var prop_name: String = "drag_%s_margin" % edge applied[prop_name] = v old_values[prop_name] = node.get(prop_name) if applied.is_empty(): return ErrorCodes.make( ErrorCodes.MISSING_REQUIRED_PARAM, "No damping params specified; provide at least one of position_speed, rotation_speed, drag_margins, drag_horizontal_enabled, drag_vertical_enabled" ) _undo_redo.create_action("MCP: Set camera damping on %s" % node.name) for prop_name in applied: _undo_redo.add_do_property(node, prop_name, applied[prop_name]) _undo_redo.add_undo_property(node, prop_name, old_values[prop_name]) _undo_redo.commit_action() return { "data": { "path": node_path, "applied": applied.keys(), "values": applied, "undoable": true, } } # ============================================================================ # camera_follow_2d # ============================================================================ func follow_2d(params: Dictionary) -> Dictionary: var resolved := _resolve_camera(params) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var type_str: String = resolved.type var scene_root: Node = resolved.scene_root if type_str != "2d": return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "camera_follow_2d requires a Camera2D (got %s)" % node.get_class() ) var target_path: String = params.get("target_path", "") if target_path.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: target_path") var target := McpScenePath.resolve(target_path, scene_root) if target == null: return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, "Target not found: %s" % target_path) if not (target is Node2D) and target != scene_root: return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "Follow target must be a Node2D (got %s)" % target.get_class() ) if target == node: return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Camera cannot follow itself") if target.is_ancestor_of(node) and node.get_parent() != target: # A non-parent ancestor — still valid to reparent under (direct parent). pass if node.is_ancestor_of(target): return ErrorCodes.make( ErrorCodes.INVALID_PARAMS, "Cannot follow a descendant of the camera" ) var smoothing_speed := float(params.get("smoothing_speed", 5.0)) var zero_transform: bool = bool(params.get("zero_transform", true)) var old_parent := node.get_parent() var old_idx: int = node.get_index() if old_parent != null else 0 var old_position = node.get("position") var old_rotation = node.get("rotation") var old_smoothing_enabled: bool = bool(node.get("position_smoothing_enabled")) var old_smoothing_speed: float = float(node.get("position_smoothing_speed")) var already_child: bool = old_parent == target var reparented: bool = not already_child _undo_redo.create_action("MCP: Camera follow %s" % target.name) if reparented: _undo_redo.add_do_method(old_parent, "remove_child", node) _undo_redo.add_do_method(target, "add_child", node, true) _undo_redo.add_do_method(node, "set_owner", scene_root) _undo_redo.add_do_reference(node) if zero_transform: if target is Node2D: _undo_redo.add_do_property(node, "position", Vector2.ZERO) _undo_redo.add_undo_property(node, "position", old_position) _undo_redo.add_do_property(node, "rotation", 0.0) _undo_redo.add_undo_property(node, "rotation", old_rotation) _undo_redo.add_do_property(node, "position_smoothing_enabled", true) _undo_redo.add_undo_property(node, "position_smoothing_enabled", old_smoothing_enabled) if smoothing_speed > 0.0: _undo_redo.add_do_property(node, "position_smoothing_speed", smoothing_speed) _undo_redo.add_undo_property(node, "position_smoothing_speed", old_smoothing_speed) if reparented: _undo_redo.add_undo_method(target, "remove_child", node) _undo_redo.add_undo_method(old_parent, "add_child", node, true) _undo_redo.add_undo_method(old_parent, "move_child", node, old_idx) _undo_redo.add_undo_method(node, "set_owner", scene_root) _undo_redo.add_undo_reference(node) _undo_redo.commit_action() return { "data": { "path": McpScenePath.from_node(node, scene_root), "target_path": McpScenePath.from_node(target, scene_root), "reparented": reparented, "smoothing_speed": smoothing_speed, "zero_transform": zero_transform and (target is Node2D), "undoable": true, } } # ============================================================================ # camera_get # ============================================================================ func get_camera(params: Dictionary) -> Dictionary: var _scene_check := McpNodeValidator.require_scene_or_error() if _scene_check.has("error"): return _scene_check var scene_root: Node = _scene_check.scene_root var camera_path: String = params.get("camera_path", "") var node: Node = null var resolved_via: String = "" if camera_path.is_empty(): # Empty: prefer the viewport's active camera. In headless editor CI, # Camera2D.is_current() can lag make_current() briefly even after the # viewport slot has switched; falling through to "first" during that # window makes camera_get("") nondeterministic. var all_cams := _list_cameras_in_scene(scene_root, "") var logical_current := _logical_current_camera(scene_root) if logical_current != null and all_cams.has(logical_current): node = logical_current resolved_via = "current" var viewport_current := _viewport_current_camera(scene_root) if node == null and viewport_current != null and all_cams.has(viewport_current): node = viewport_current resolved_via = "current" for cam in all_cams: if node != null: break if _is_current(cam): node = cam resolved_via = "current" break if node == null and not all_cams.is_empty(): node = all_cams[0] resolved_via = "first" else: node = McpScenePath.resolve(camera_path, scene_root) if node == null: return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_node_error(camera_path, scene_root)) if not _is_camera(node): return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "Node %s is not a camera (got %s)" % [camera_path, node.get_class()] ) resolved_via = "path" if node == null: return { "data": { "path": "", "type": "", "class": "", "current": false, "properties": {}, "resolved_via": "not_found", } } var type_str := _camera_type_str(node) var keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D var prop_types := _property_type_map(node) var props: Dictionary = {} var is_current_effective := _resolve_current(scene_root, node) for key in keys: if key == "current": props[key] = is_current_effective continue if prop_types.has(key): props[key] = CameraValues.serialize(node.get(key)) return { "data": { "path": McpScenePath.from_node(node, scene_root), "type": type_str, "class": node.get_class(), "current": is_current_effective, "properties": props, "resolved_via": resolved_via, } } # ============================================================================ # camera_list # ============================================================================ func list_cameras(_params: Dictionary) -> Dictionary: var _scene_check := McpNodeValidator.require_scene_or_error() if _scene_check.has("error"): return _scene_check var scene_root: Node = _scene_check.scene_root var cams := _list_cameras_in_scene(scene_root, "") var out: Array[Dictionary] = [] var logical_2d := _logical_current_camera(scene_root, "2d") var logical_3d := _logical_current_camera(scene_root, "3d") for cam in cams: out.append({ "path": McpScenePath.from_node(cam, scene_root), "class": cam.get_class(), "type": _camera_type_str(cam), "current": _resolve_current_with_logicals(cam, logical_2d, logical_3d), }) return {"data": {"cameras": out}} # ============================================================================ # camera_apply_preset # ============================================================================ func apply_preset(params: Dictionary) -> Dictionary: var preset_name: String = params.get("preset", "") if preset_name.is_empty(): return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: preset") var overrides: Dictionary = params.get("overrides", {}) var blueprint = CameraPresets.build(preset_name, overrides) if blueprint == null: return ErrorCodes.make( ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown preset '%s'. Valid: %s" % [preset_name, ", ".join(CameraPresets.list_presets())] ) var parent_path: String = params.get("parent_path", "") var node_name: String = params.get("name", "") var type_str: String = params.get("type", String(blueprint.get("default_type", "2d"))) var make_current: bool = bool(params.get("make_current", true)) if node_name.is_empty(): node_name = preset_name.capitalize() if not _VALID_TYPES.has(type_str): return ErrorCodes.make( ErrorCodes.VALUE_OUT_OF_RANGE, "Invalid camera type '%s'. Valid: %s" % [type_str, ", ".join(_VALID_TYPES.keys())] ) var _scene_check := McpNodeValidator.require_scene_or_error() if _scene_check.has("error"): return _scene_check var scene_root: Node = _scene_check.scene_root var parent: Node = scene_root if not parent_path.is_empty(): parent = McpScenePath.resolve(parent_path, scene_root) if parent == null: return ErrorCodes.make(ErrorCodes.NODE_NOT_FOUND, McpScenePath.format_parent_error(parent_path, scene_root)) var node := _instantiate_camera(type_str) node.name = node_name var preset_props: Dictionary = blueprint.get("properties", {}) var valid_keys: Array = _KEYS_2D if type_str == "2d" else _KEYS_3D var prop_types := _property_type_map(node) var applied: Array[String] = [] for prop in preset_props: var prop_name := String(prop) if not (prop_name in valid_keys): continue # Silently skip preset keys that don't apply to this camera class. # `current` lives on methods, not as a writable property on Camera2D — # always handled via the make_current path below. if prop_name == "current": continue var prop_type: int = prop_types.get(prop_name, TYPE_NIL) if prop_type == TYPE_NIL: continue var coerce_result := CameraValues.coerce(prop_name, preset_props[prop_name], prop_type) if not coerce_result.ok: return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, String(coerce_result.error)) node.set(prop_name, coerce_result.value) applied.append(prop_name) _undo_redo.create_action( "MCP: Apply camera preset %s" % preset_name, UndoRedo.MERGE_DISABLE, scene_root ) _undo_redo.add_do_method(parent, "add_child", node, true) _undo_redo.add_do_method(node, "set_owner", scene_root) _undo_redo.add_do_reference(node) if make_current: _add_make_current_to_action(node, type_str, scene_root) _undo_redo.add_undo_method(parent, "remove_child", node) _undo_redo.commit_action() if make_current: _verify_current_after_commit(node) return { "data": { "path": McpScenePath.from_node(node, scene_root), "parent_path": McpScenePath.from_node(parent, scene_root), "name": node_name, "preset": preset_name, "type": type_str, "class": _VALID_TYPES[type_str], "applied": applied, "current": bool(make_current), "undoable": true, } } # ============================================================================ # Helpers # ============================================================================ static func _instantiate_camera(type_str: String) -> Node: match type_str: "2d": return Camera2D.new() "3d": return Camera3D.new() return null static func _is_camera(node: Node) -> bool: return node is Camera2D or node is Camera3D static func _camera_type_str(node: Node) -> String: if node is Camera2D: return "2d" if node is Camera3D: return "3d" return "" func _resolve_camera(params: Dictionary) -> Dictionary: var resolved := McpNodeValidator.resolve_or_error( params.get("camera_path", ""), "camera_path", ) if resolved.has("error"): return resolved var node: Node = resolved.node var node_path: String = resolved.path var scene_root: Node = resolved.scene_root if not _is_camera(node): return ErrorCodes.make( ErrorCodes.WRONG_TYPE, "Node %s is not a camera (got %s)" % [node_path, node.get_class()] ) return { "node": node, "path": node_path, "type": _camera_type_str(node), "scene_root": scene_root, } ## Walk the edited scene for cameras. class_filter: "2d", "3d", or "" for all. static func _list_cameras_in_scene(scene_root: Node, class_filter: String) -> Array: var result: Array = [] if scene_root == null: return result _collect_cameras(scene_root, class_filter, result) return result static func _collect_cameras(node: Node, class_filter: String, out: Array) -> void: var matches := false match class_filter: "2d": matches = node is Camera2D "3d": matches = node is Camera3D _: matches = node is Camera2D or node is Camera3D if matches: out.append(node) for child in node.get_children(): _collect_cameras(child, class_filter, out) ## Build a name -> property-type dict from the object's property list. ## Single walk of get_property_list() amortizes lookups across a batch of ## properties in configure / apply_preset. static func _property_type_map(obj: Object) -> Dictionary: var out: Dictionary = {} if obj == null: return out for prop in obj.get_property_list(): out[prop.name] = int(prop.get("type", TYPE_NIL)) return out