1152 lines
39 KiB
GDScript
1152 lines
39 KiB
GDScript
@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
|